diff --git a/.actrc b/.actrc
new file mode 100644
index 00000000..693b5f58
--- /dev/null
+++ b/.actrc
@@ -0,0 +1,27 @@
+# act runner config (project-level).
+# Suppresses the interactive image-size prompt by pinning ubuntu-latest
+# to a specific act container image.
+#
+# Tag choice rationale: use `:act-22.04`, NOT `:act-latest`. The
+# `:act-22.04` tag ships Node 22 pre-installed, so `actions/setup-node@v4`
+# can resolve Node 22 without re-installing it inside the bind-mounted
+# workspace, and the image is on the Medium-size footprint. The generic
+# `:act-latest` tag is Ubuntu 22.04 without Node pre-installed — Node is
+# still installable via the workflow's `actions/setup-node@v4` step, so
+# the tag-vs-workflow choice doesn't actually fix the *symptom* observed
+# in this sandbox. The real cause: `actions/checkout@v4` (forced by
+# `--no-skip-checkout`) checks out **committed HEAD**, not the working
+# tree. Any uncommitted changes to `package.json` (e.g. the
+# `check-links` script added in the same session) won't be visible to
+# the container step.
+#
+# Two ways to clear that:
+# (a) commit the package.json change so HEAD has it, OR
+# (b) skip `actions/checkout` (the chosen path below) so act uses the
+# bind-mounted working tree as-is, including uncommitted changes.
+#
+# We pick (b) so contributors don't need a fresh commit to verify a
+# cross-link change locally via `act`. The trade-off: skipping checkout
+# means act trusts the bind-mounted tree's git state (lock files,
+# truncated pulls, etc.) which is the standard `act` behavior.
+-P ubuntu-latest=catthehacker/ubuntu:act-22.04
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..bd376151
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,240 @@
+# GitHub Actions CI for evolver.
+#
+# This workflow exercises the full `make watch-once` pipeline end-to-end
+# against an in-memory fixture mutation, then asserts the local Slack
+# receiver (scripts/dev-slack-receiver.js) captured the expected payload.
+# Its job is to catch regressions in the dev-watch env wiring — e.g. a
+# future change to scripts/dev-watch.sh that breaks SLACK_WEBROCK_URL
+# propagation, AWS_BEDROCK_URL resolution, STATE_DIR override, or the
+# DRY_RUN=0 leak guard.
+#
+# The contract:
+# 1. Overwrite dev-fixtures/aws.html with a synthetic doc that exactly
+# matches messages_route.js coverage plus ONE fresh family/major/minor
+# (opus/4/9). The synthetic doc has zero drift, so any alert the
+# script posts is unambiguously caused by the CI's mutation.
+# 2. Run `make watch-once` (starts receiver + tails log + runs watch
+# script once + cleans up).
+# 3. Assert dev-fixtures/receiver.log contains:
+# - the new canon (opus/4/9)
+# - the "new family/major/minor" section header
+# - a POST /slack line (proves the curl actually went out)
+# 4. Restore the source-controlled dev-fixtures/aws.html in an
+# `if: always()` step so a failed CI run never leaves the working
+# tree dirty for the next attempt. dev-fixtures/state/ and the
+# .receiver.* scratch files are gitignored, so they don't need
+# explicit restoration.
+#
+# NOTE: link-check is intentionally NOT a job in this workflow. It
+# lives in `.github/workflows/link-check.yml` as its own dedicated
+# workflow with its own status badge, concurrency group, and `actions/
+# setup-node@v4` pin — `git log --grep="drop redundant link-check"`
+# surfaces the move commit if you ever need to reconcile the two.
+
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ workflow_dispatch:
+
+# Cancel any in-progress run on the same ref so a push-and-PR combo
+# never races on dev-fixtures/aws.html.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+# Default to read-only token. Both jobs only need `contents: read` for
+# `actions/checkout@v4`, so we hoist the permission to the workflow
+# level and don't have to repeat it per job.
+permissions:
+ contents: read
+
+jobs:
+ watch-once-fixture:
+ name: make watch-once against fixture mutation
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # dev-slack-receiver.js requires Node. evolver's engines field
+ # requires >=22.12; pin to the closest LTS. ubuntu-latest already
+ # ships bash + curl + jq + make + python3 — no apt install needed.
+ - name: Setup Node 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Verify required tools
+ run: |
+ set -euo pipefail
+ for cmd in bash node make jq curl grep; do
+ command -v "$cmd" >/dev/null 2>&1 || { echo "missing required command: $cmd"; exit 1; }
+ echo " $cmd: $($cmd --version 2>&1 | head -1)"
+ done
+
+ - name: Run make watch-once against fixture mutation
+ run: |
+ set -euo pipefail
+
+ # 1. Snapshot the source-controlled aws.html so we can restore
+ # it in the next step regardless of pass/fail.
+ cp dev-fixtures/aws.html /tmp/aws-original.html
+
+ # 2. Clear any state carried over from a previous run on the
+ # same self-hosted runner (we're on github-hosted so this
+ # is a no-op, but cheaper to be explicit than to debug).
+ rm -rf dev-fixtures/state
+ rm -f dev-fixtures/.receiver.pid dev-fixtures/.receiver.port dev-fixtures/receiver.log
+
+ # 3. Synthetic fixture: exact messages_route.js coverage so the
+ # baseline has zero drift, then ONE fresh family/major/minor
+ # (opus/4/9) so the only alert the watch script can produce
+ # is the one CI just introduced. If this assertion fails,
+ # it's a regression in the watch script — not fixture drift.
+ cat > dev-fixtures/aws.html <<'HTML'
+
+ - global.anthropic.claude-opus-4-7
+ - global.anthropic.claude-haiku-4-5-20251001-v1:0
+ - global.anthropic.claude-sonnet-4-6
+ - global.anthropic.claude-opus-4-9
+
+ HTML
+
+ # 4. End-to-end pipeline. Starts receiver, runs watch.sh once
+ # with all env vars wired (STATE_DIR / MESSAGES_ROUTE_FILE /
+ # AWS_BEDROCK_URL / SLACK_WEBHOOK_URL / DRY_RUN=0), then
+ # kills receiver + tail cleanly.
+ make watch-once
+
+ # 5. Echo the receiver log for debugging — it's both the
+ # assertion target AND the failure-context payload.
+ echo "=== dev-fixtures/receiver.log ==="
+ cat dev-fixtures/receiver.log
+ echo "=== end receiver log ==="
+
+ # 6. Assertions. Each one prints the full log on failure so a
+ # failing CI run has enough context to diagnose without an
+ # artifacts download.
+ grep -q 'opus/4/9' dev-fixtures/receiver.log || {
+ echo "FAIL: receiver log does not mention Opus/4/9 — the new canon never appeared in the Slack payload"
+ exit 1
+ }
+ grep -q 'new family/major/minor not yet in' dev-fixtures/receiver.log || {
+ echo "FAIL: receiver log is missing the 'new family/major/minor' section header"
+ exit 1
+ }
+ grep -q 'POST /slack' dev-fixtures/receiver.log || {
+ echo "FAIL: receiver log is missing a 'POST /slack' entry — the curl never reached the receiver"
+ exit 1
+ }
+
+ # 7. Negative assertion: only ONE new family/major/minor was
+ # introduced, so the message must report exactly "1".
+ grep -Eq 'published 1 new family/major/minor' dev-fixtures/receiver.log || {
+ echo "FAIL: receiver log does not report exactly 1 new family/major/minor — extra drift or no alert"
+ exit 1
+ }
+
+ echo "PASS: fixture mutation produced the expected Slack payload"
+
+ # Restore the source-controlled aws.html so a failed CI run never
+ # poisons the next attempt's checkout. dev-fixtures/state/, the
+ # .receiver.* scratch files, and dev-fixtures/receiver.log are all
+ # gitignored, so they don't need explicit cleanup.
+ - name: Restore dev-fixtures/aws.html
+ if: always()
+ run: |
+ if [ -f /tmp/aws-original.html ]; then
+ cp /tmp/aws-original.html dev-fixtures/aws.html
+ echo "Restored dev-fixtures/aws.html from /tmp/aws-original.html"
+ else
+ echo "No snapshot to restore from — skipping"
+ fi
+
+ # Catch a class of regressions the watch-once job doesn't: a future
+ # change to .gitignore / .npmignore / package.json `files` that would
+ # leak dev-fixtures runtime artifacts (state/, receiver.log, .receiver.*
+ # pid/port) into the published npm tarball. Runs in parallel with
+ # watch-once-fixture since it doesn't share any state with it.
+ pack-tarball-clean:
+ name: pack tarball excludes dev-fixtures runtime artifacts
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # npm itself is shipping with ubuntu-latest, but we declare Node
+ # 22 anyway so npm resolves to a known-good version (>=10) — the
+ # shipped version moves with the runner image.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: npm pack --dry-run + exclusion assertions
+ run: |
+ set -euo pipefail
+
+ # Capture the tarball listing. `npm pack --dry-run` prints each
+ # included file as `npm notice ` to stderr; we
+ # redirect 2>&1 to capture the full listing in one string.
+ LISTING="$(npm pack --dry-run 2>&1)"
+ echo '=== npm pack --dry-run listing ==='
+ echo "$LISTING"
+ echo '===================================='
+
+ # Negative assertions: these runtime artifacts MUST NOT ship.
+ # If a future change to dev-fixtures/.gitignore, evolver/.gitignore,
+ # or package.json `files` accidentally lets one of these through,
+ # `npm install` would dump scratch state into consumer's working
+ # dir or expose internal logs / ports. The leading `[[:space:]]`
+ # anchors each path as a separate token in the `npm notice` listing
+ # (so `dev-fixtures/state` doesn't accidentally match
+ # `dev-fixtures/stateful.json`), and the trailing `\b` rejects
+ # mid-token matches.
+ for path in \
+ dev-fixtures/state \
+ dev-fixtures/receiver.log \
+ dev-fixtures/.receiver.pid \
+ dev-fixtures/.receiver.port \
+ ; do
+ if echo "$LISTING" | grep -Eq "[[:space:]]${path}\b"; then
+ echo "FAIL: '${path}' leaked into the published tarball — would expose runtime artifacts to consumers."
+ echo "First matching line:"
+ echo "$LISTING" | grep -m1 "${path}" || true
+ exit 1
+ fi
+ echo " OK excluded: ${path}"
+ done
+
+ # Positive assertions: legitimate dev-fixtures and the Makefile
+ # MUST ship. Without these, a future packaging regression that
+ # empties the tarball entirely would silently pass the negative
+ # assertions above. We anchor on END-OF-LINE here — not on
+ # `\b` — because `\b` matches between any word/non-word char
+ # (including `l`→`.`), so `Makefile\b` would still match a
+ # hypothetical `Makefile.frontend`. Since npm pack --dry-run
+ # always lists each path as the last token of its line, EOL
+ # anchoring is correct. (The negative branch keeps `\b`
+ # because we want to be tolerant of `dev-fixtures/state/`.)
+ for path in \
+ dev-fixtures/aws.html \
+ dev-fixtures/messages_route.js \
+ dev-fixtures/README.md \
+ Makefile \
+ ; do
+ if ! echo "$LISTING" | grep -Eq "[[:space:]]${path}\$"; then
+ echo "FAIL: '${path}' missing from tarball — files-array or .gitignore is broken."
+ exit 1
+ fi
+ echo " OK present: ${path}"
+ done
+
+ echo 'PASS: tarball contains exactly the expected set of dev-fixtures + Makefile'
+
+
diff --git a/.github/workflows/link-check-dev.yml b/.github/workflows/link-check-dev.yml
new file mode 100644
index 00000000..c2b6580c
--- /dev/null
+++ b/.github/workflows/link-check-dev.yml
@@ -0,0 +1,131 @@
+name: link-check-dev
+
+# Slim, exit-tolerant sister of the strict `link-check` workflow.
+#
+# Why a separate file:
+# - Triggers ONLY on PRs whose changeset touches `scripts/**`
+# (path filter). Never runs on push-to-main; never blocks a
+# PR's required status checks.
+# - Job-level `continue-on-error: true` so a soft-fail shows up
+# in the PR's check status but does NOT prevent merge.
+# - Same 6-file scope as the strict check (`npm run check-links`
+# unchanged). The "slim" axis is the trigger filter + fast-
+# feedback outcome, not a shrunk scope.
+#
+# Per-branch capture:
+# Each run writes to `evolver/artifacts//dev-check.log`
+# in the runner workspace AND publishes it as a GH-Actions
+# artifact named `link-check-dev-`. The auto-commit
+# step below is GUARDED to push events, so on this PR-only
+# workflow it is skipped on every run (saves ~10s of dead
+# work per PR run; the upload-artifact above is the durable
+# per-PR record). If the trigger set is later widened to
+# include `push` events, the auto-commit step will fire and
+# persist the captured log back to the PR source branch.
+
+# Cancel superseded runs on the same ref so a PR with rapid pushes
+# to the same branch only spends CI minutes on the most-recent commit.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ pull_request:
+ paths:
+ - 'scripts/**'
+
+# contents:read is sufficient for writing into the runner workspace;
+# the conditional `git push` below is best-effort and never blocks.
+permissions:
+ contents: read
+
+jobs:
+ link-check-dev:
+ name: dev-only link-check on scripts/** change
+ runs-on: ubuntu-latest
+ timeout-minutes: 1
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+
+ # Same defensive sanitization as the strict workflow: strip any
+ # [a-zA-Z0-9._/-] characters from the head_ref so a crafted
+ # branch name can't escape the evolver/artifacts/ namespace.
+ - name: Compute sanitized branch directory
+ id: branch-dir
+ env:
+ BRANCH_REF_RAW: ${{ github.head_ref }}
+ run: |
+ # `tr -cd 'a-zA-Z0-9._/-'` strips everything outside the
+ # GH-allowed branch-name charset. GH itself rejects empty /
+ # `.`-prefixed / `..` branch names, so this regex always
+ # produces a non-empty result for any valid ref — the
+ # `[ -n ... ]` guard below is purely defensive.
+ BRANCH_DIR=$(printf '%s' "$BRANCH_REF_RAW" | tr -cd 'a-zA-Z0-9._/-')
+ [ -n "$BRANCH_DIR" ] || { echo "branch-ref sanitize produced empty"; exit 1; }
+ echo "BRANCH_DIR=$BRANCH_DIR" >> "$GITHUB_OUTPUT"
+ echo "sanitized '$BRANCH_REF_RAW' -> '$BRANCH_DIR'"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ # `set -uo pipefail` WITHOUT `-e`: the JOB-level
+ # `continue-on-error: true` catches any non-zero here and lets
+ # subsequent steps run; we want a durable capture regardless of
+ # the link-check verdict.
+ - name: Capture verdict + write per-branch dev-only log
+ env:
+ BRANCH_DIR: ${{ steps.branch-dir.outputs.BRANCH_DIR }}
+ run: |
+ set -uo pipefail
+ mkdir -p "evolver/artifacts/$BRANCH_DIR"
+ {
+ echo '=== provenance ==='
+ echo "Repo: ${{ github.repository }}"
+ echo "Branch: ${{ github.head_ref }}"
+ echo "Commit: ${{ github.sha }}"
+ echo "Trigger: ${{ github.event_name }} (dev-only, exit-tolerant)"
+ echo "Timestamp: $(date -Iseconds)"
+ echo ''
+ echo '=== Step 1: actions/setup-node@v4 (resolved Node) ==='
+ node --version
+ echo ''
+ echo '=== Step 2: npm run check-bedrock-prefix (dev-only) ==='
+ npm run check-bedrock-prefix
+ echo ''
+ echo '=== Step 3: npm run check-links (dev-only) ==='
+ npm run check-links
+ } > "evolver/artifacts/$BRANCH_DIR/dev-check.log" 2>&1
+
+ - name: Upload dev-only artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: link-check-dev-${{ steps.branch-dir.outputs.BRANCH_DIR }}
+ path: evolver/artifacts/${{ steps.branch-dir.outputs.BRANCH_DIR }}/dev-check.log
+ if-no-files-found: warn
+
+ # Auto-commit-back to the PR source branch. The if-guard
+ # (`if: github.event_name == 'push'`) makes GH-Actions skip
+ # this step entirely on PR events — the auto-commit body
+ # never runs on this workflow. ("No commit on PRs" intent
+ # is now explicit in YAML.)
+ - name: Auto-commit capture to PR source branch
+ if: github.event_name == 'push'
+ run: |
+ set -uo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add "evolver/artifacts/${{ steps.branch-dir.outputs.BRANCH_DIR }}/dev-check.log" || true
+ if git diff --cached --quiet; then
+ echo 'no changes to commit — capture identical to existing'
+ exit 0
+ fi
+ git commit \
+ -m "ci: capture dev-only link-check log for ${{ steps.branch-dir.outputs.BRANCH_DIR }}@${{ github.sha }}" \
+ || echo 'commit-failed-skipped'
+ git push \
+ "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" \
+ "HEAD:${{ steps.branch-dir.outputs.BRANCH_DIR }}" \
+ 2>&1 | tail -5 \
+ || echo 'push-skipped (likely read-only token on this event type)'
diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml
new file mode 100644
index 00000000..3db55b19
--- /dev/null
+++ b/.github/workflows/link-check.yml
@@ -0,0 +1,140 @@
+name: link-check
+
+# Dedicated workflow so the README status badge
+# (https://github.com/EvoMap/evolver/actions/workflows/link-check.yml/badge.svg)
+# reflects ONLY the cross-link audit verdict, not the entire CI suite.
+#
+# Per-branch captures (this round):
+# Every run writes a log to `evolver/artifacts//link-check.log`
+# in the runner workspace AND publishes it as a GH-Actions artifact
+# named `link-check-`. For push-to-main runs, the log is
+# also auto-committed to `evolver/artifacts/main/link-check.log` so
+# the canonical green/red verdict at HEAD is durable across sessions.
+# Pull-request runs default to upload-artifact only; the GITHUB_TOKEN
+# on `pull_request` events is read-only, so the same auto-commit
+# step is GUARDED to `github.event_name == 'push'` to avoid hitting
+# the auth boundary every PR.
+#
+# Why not `pull_request_target` for commit-back on PR events: that
+# event type runs any checkout-and-run-our-script step against the
+# PR's tree with full token scope, which is a documented footgun for
+# workflows that execute PR-contributed code. Our workflow only
+# runs `node scripts/check-readme-links.js` (a markdown parser) but
+# the trade-off is real for future scripts/ additions. The current
+# design (read-only on PR + GH-artifact durable + write to main on
+# push) keeps the badge accurate on the README without expanding
+# the security surface.
+
+# Cancel superseded runs on the same ref so a rapid series of pushes to
+# the same PR only spends CI minutes on the most-recent commit.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+# Bumped from `contents: read` to `contents: write` because the
+# workflow now auto-commits captured logs back to the
+# `evolver/artifacts/main/` subdir on push-to-main runs. The bump
+# is scoped to this workflow file only; `link-check-dev.yml` keeps
+# `contents: read` because its best-effort commit-back is non-fatal.
+permissions:
+ contents: write
+
+jobs:
+ link-check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 1
+ steps:
+ - uses: actions/checkout@v4
+
+ # Compute a sanitized branch-directory name once, then reuse for
+ # capture, upload, and (when applicable) auto-commit. Strips any
+ # character outside [a-zA-Z0-9._/-] from the branch ref so a
+ # crafted branch name can't escape the `evolver/artifacts/`
+ # namespace. GitHub itself rejects branch names starting with
+ # `.` or `..` — this is belt-and-suspenders defense-in-depth.
+ - name: Compute sanitized branch directory
+ id: branch-dir
+ env:
+ BRANCH_REF_RAW: ${{ github.head_ref || github.ref_name }}
+ run: |
+ # `tr -cd 'a-zA-Z0-9._/-'` strips everything outside the
+ # GH-allowed branch-name charset. GH itself rejects empty /
+ # `.`-prefixed / `..` branch names, so this regex always
+ # produces a non-empty result for any valid ref — the
+ # `[ -n ... ]` guard below is purely defensive.
+ BRANCH_DIR=$(printf '%s' "$BRANCH_REF_RAW" | tr -cd 'a-zA-Z0-9._/-')
+ [ -n "$BRANCH_DIR" ] || { echo "branch-ref sanitize produced empty"; exit 1; }
+ echo "BRANCH_DIR=$BRANCH_DIR" >> "$GITHUB_OUTPUT"
+ echo "sanitized '$BRANCH_REF_RAW' -> '$BRANCH_DIR'"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ # `set -uo pipefail` WITHOUT `-e`: even when the link-check exits
+ # 1 (broken refs), the capture file is still written to the
+ # runner workspace. The next step (`Upload per-branch artifact`)
+ # has `if: always()` so it runs even when this step fails, giving
+ # reviewers a durable per-PR capture via the GH-artifact download.
+ # The non-zero npm exit propagates up to the workflow as a fail —
+ # which is what we WANT here (red badge on broken refs).
+ - name: Capture verdict + write per-branch log
+ env:
+ BRANCH_DIR: ${{ steps.branch-dir.outputs.BRANCH_DIR }}
+ run: |
+ set -uo pipefail
+ mkdir -p "evolver/artifacts/$BRANCH_DIR"
+ {
+ echo '=== provenance ==='
+ echo "Repo: ${{ github.repository }}"
+ echo "Branch: ${{ github.head_ref || github.ref_name }}"
+ echo "Commit: ${{ github.sha }}"
+ echo "Trigger: ${{ github.event_name }}"
+ echo "Timestamp: $(date -Iseconds)"
+ echo ''
+ echo '=== Step 1: actions/setup-node@v4 (resolved Node) ==='
+ node --version
+ echo ''
+ echo '=== Step 2: npm run check-bedrock-prefix ==='
+ npm run check-bedrock-prefix
+ echo ''
+ echo '=== Step 3: npm run check-links ==='
+ npm run check-links
+ } > "evolver/artifacts/$BRANCH_DIR/link-check.log" 2>&1
+
+ # `if: always()` so RED-badge runs still produce a downloadable
+ # artifact. Without this guard, a broken-ref capture would never
+ # reach reviewers through the GH-artifact UI — they'd have to
+ # dig through the build log instead.
+ - name: Upload per-branch artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: link-check-${{ steps.branch-dir.outputs.BRANCH_DIR }}
+ path: evolver/artifacts/${{ steps.branch-dir.outputs.BRANCH_DIR }}/link-check.log
+ if-no-files-found: warn
+
+ # Auto-commit-back to main ONLY on push-to-main. RED push-to-main
+ # events also skip (default sequential behavior — failed capture
+ # means auto-commit doesn't run, so a RED log never auto-grows
+ # main; the [skip ci] tag in the commit message prevents
+ # re-triggering on the auto-commit's SHA).
+ - name: Auto-commit capture to main (push-to-main only)
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: |
+ set -uo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add "evolver/artifacts/main/link-check.log" || true
+ if git diff --cached --quiet; then
+ echo 'no changes to commit — capture identical to existing'
+ exit 0
+ fi
+ git commit -m "ci: capture link-check log for ${{ github.sha }} [skip ci]"
+ git push origin main
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..394ff591
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+# Makefile for evolver developer tooling.
+#
+# The `watch*` targets iterate on scripts/bedrock-alias-watch.sh against
+# a local Slack receiver, so you can see the Slack payload in real time
+# as you edit dev-fixtures/aws.html.
+#
+# Usage:
+# make watch # 60s loop (override: WATCH_INTERVAL=10 make watch)
+# make watch-fresh # clear dev-fixtures/state, then watch
+# make watch-once # run the watch script once, no loop
+# make watch-tail # tail dev-fixtures/receiver.log (no watch loop)
+# # useful when `make watch` is already running in
+# # another terminal and you want a second window
+
+# Resolve ROOT from the Makefile directory so `make watch-fresh`
+# works from any cwd (e.g. `cd src/proxy && make -f ../../Makefile watch-fresh`).
+# Without this, the rm would target the user's cwd and silently no-op if
+# the contributor isn't at the repo root. See git log --grep="tooling"
+# for the follow-up rationale.
+ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
+
+.PHONY: watch watch-fresh watch-once watch-tail
+
+WATCH_INTERVAL ?= 60
+
+watch:
+ @WATCH_INTERVAL='$(WATCH_INTERVAL)' bash scripts/dev-watch.sh
+
+watch-fresh:
+ @if [ "$(WATCH_CONFIRM)" != "1" ]; then \
+ echo "watch-fresh: would rm -rf $(ROOT)/dev-fixtures/state — confirm with: WATCH_CONFIRM=1 make watch-fresh"; \
+ exit 1; \
+ fi
+ @echo "watch-fresh: removing $(ROOT)/dev-fixtures/state (per WATCH_CONFIRM=1)"
+ @rm -rf $(ROOT)/dev-fixtures/state
+ @$(MAKE) watch
+
+watch-once:
+ @WATCH_INTERVAL=0 bash scripts/dev-watch.sh
+
+watch-tail:
+ @if [ ! -f dev-fixtures/receiver.log ]; then \
+ echo 'make watch-tail: dev-fixtures/receiver.log does not exist yet.'; \
+ echo 'Start the watch in another terminal first:'; \
+ echo ' make watch # or make watch-once for a single run'; \
+ exit 1; \
+ fi
+ @tail -n 0 -F dev-fixtures/receiver.log
diff --git a/README.ja-JP.md b/README.ja-JP.md
index c8ecbf7a..e7929793 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -5,6 +5,7 @@
[](https://nodejs.org/)
[](https://www.npmjs.com/package/@evomap/evolver)
[](https://arxiv.org/abs/2604.15097)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)

@@ -115,9 +116,25 @@ npm install
Evolver が OpenClaw セッション内で実行されると、ホストが stdout のディレクティブ(`sessions_spawn(...)` など)を拾い、後続のアクションを自動で連鎖させます。
+### EvoMap ネットワークへの接続(任意)
+
+[EvoMap ネットワーク](https://evomap.ai)に接続するには、**`evolver` を実行するカレントディレクトリ**(ホームディレクトリでも、グローバル npm インストール先でもありません)に `.env` ファイルを作成します。Evolver は実行のたびに `process.cwd()` から `.env` を読み込むので、プロジェクトごとに別々の `.env` を置くこともできます:
+
+```bash
+# Node ID を取得するには https://evomap.ai で登録してください
+A2A_HUB_URL=https://evomap.ai
+A2A_NODE_ID=your_node_id_here
+```
+
+> **注記**: Evolver は `.env` なしで完全にオフラインで動作します。Hub 接続は、スキル共有、ワーカープール、進化リーダーボードなどのネットワーク機能にのみ必要です。
+
+## 開発者ワークフロー
+
+`npm install -g @evomap/evolver` でインストール済みの方は、このセクション全体をスキップしてください ―― 本 README の残りはパブリッシュ済み CLI のユーザー向けです。本セクション以下のサブセクションは貢献者向けです:ソースからのエンジン実行、`scripts/` の反復開発、PR の送付。今後追加する貢献者向けコンテンツは、独立した `## ` セクションではなく、本セクション下の `### ` 子セクションとして配置してください ―― ユーザー向けと貢献者向けの分離をクリーンに保つためです。
+
### ソースから実行(貢献者向け)
-すでに `npm install -g @evomap/evolver` を済ませた方はこのセクションを完全にスキップしてください。ソース実行パスはエンジン本体を触る貢献者のみを対象としています。
+ソース実行パスはエンジン本体を触る貢献者のみを対象としています。
```bash
git clone https://github.com/EvoMap/evolver.git
@@ -130,17 +147,21 @@ node index.js --review # evolver --review と等価
node index.js --loop # evolver --loop と等価
```
-### EvoMap ネットワークへの接続(任意)
+### ローカル開発: `make watch`
-[EvoMap ネットワーク](https://evomap.ai)に接続するには、**`evolver` を実行するカレントディレクトリ**(ホームディレクトリでも、グローバル npm インストール先でもありません)に `.env` ファイルを作成します。Evolver は実行のたびに `process.cwd()` から `.env` を読み込むので、プロジェクトごとに別々の `.env` を置くこともできます:
+ローカルでシミュレートされた AWS Bedrock の更新に対して `scripts/bedrock-alias-watch.sh` を反復開発したい場合 ―― 例えば `src/proxy/router/messages_route.js` の `KNOWN_BEDROCK_ALIASES` に新しい Anthropic モデルを追加する、あるいは監視スクリプトが日付リビジョンやリタイアメント イベントをどう検出するかを試す ―― は `make watch` をお使いください:
```bash
-# Node ID を取得するには https://evomap.ai で登録してください
-A2A_HUB_URL=https://evomap.ai
-A2A_NODE_ID=your_node_id_here
+make watch # 60 秒ループ、dev-fixtures/aws.html を編集
+WATCH_INTERVAL=10 make watch # より高速なループ
+make watch-fresh # まず state/ をクリア
+make watch-once # 1 回だけ実行、ループなし
+make watch-tail # 別のウィンドウで receiver.log を tail
```
-> **注記**: Evolver は `.env` なしで完全にオフラインで動作します。Hub 接続は、スキル共有、ワーカープール、進化リーダーボードなどのネットワーク機能にのみ必要です。
+この監視スクリプトはローカルの Slack レシーバ (`http://127.0.0.1:<ランダムポート>/slack` をリッスン) に送信するため、`dev-fixtures/aws.html` を編集すると、ターミナルに実際の Slack ペイロードが直接流れ込みます。クリーンな状態から始めるには、`make watch-fresh` を実行する (`dev-fixtures/state/` をクリア) か、そのディレクトリを直接削除してください: `rm -rf dev-fixtures/state`。
+
+「別のウィンドウで `make watch-tail` を実行する」設計の根拠、および fixture ファイルのうち .gitignore 対象とコミット対象の一覧については、[`dev-fixtures/README.md`](dev-fixtures/README.md) を参照してください。
## クイックスタート
diff --git a/README.ko-KR.md b/README.ko-KR.md
index 953e0f89..920e91e3 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -5,6 +5,7 @@
[](https://nodejs.org/)
[](https://www.npmjs.com/package/@evomap/evolver)
[](https://arxiv.org/abs/2604.15097)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)

@@ -114,9 +115,27 @@ npm install
OpenClaw 세션 내에서 Evolver가 실행되면, 호스트가 stdout 지시문(`sessions_spawn(...)` 등)을 감지하여 후속 작업을 자동으로 연쇄 실행합니다.
+### EvoMap 네트워크 연결 (선택 사항)
+
+[EvoMap 네트워크](https://evomap.ai)에 연결하려면, **`evolver`를 실행하는 현재 디렉터리**(홈 디렉터리나 전역 npm 설치 경로가 아님)에 `.env` 파일을 생성합니다. Evolver는 매 실행 시 `process.cwd()`에서 `.env`를 읽으므로, 프로젝트마다 별도의 `.env`를 둘 수 있습니다:
+
+```bash
+# Node ID를 받으려면 https://evomap.ai에서 등록하세요
+A2A_HUB_URL=https://evomap.ai
+A2A_NODE_ID=your_node_id_here
+```
+
+> **참고**: `.env` 없이도 모든 로컬 기능이 정상 작동합니다. Hub 연결은 스킬 공유, 워커 풀, 진화 리더보드 등 네트워크 기능에만 필요합니다.
+
+## 개발자 워크플로우
+
+`npm install -g @evomap/evolver`로 설치한 경우, 이 섹션 전체를 건너뛰세요 -- 본 README의 나머지는 게시된 CLI 사용자를 대상으로 합니다. 이 섹션의 하위 섹션은 기여자를 대상으로 합니다: 소스에서 엔진 실행, `scripts/` 반복 개발, PR 제출. 향후 추가될 기여자 대상 자료는 별도의 `## ` 섹션이 아니라 본 섹션의 `### ` 하위 섹션으로 배치해 주세요 -- 사용자/기여자 구분을 깔끔하게 유지하기 위함입니다.
+
+### 소스에서 실행(기여자 전용)
+
### 소스에서 실행(기여자 전용)
-`npm install -g @evomap/evolver`로 이미 설치한 경우 이 섹션을 완전히 건너뛰세요. 소스 실행 경로는 엔진 자체를 수정하려는 기여자만을 위한 것입니다.
+소스 실행 경로는 엔진 자체를 수정하려는 기여자만을 위한 것입니다.
```bash
git clone https://github.com/EvoMap/evolver.git
@@ -129,17 +148,21 @@ node index.js --review # evolver --review와 동일
node index.js --loop # evolver --loop과 동일
```
-### EvoMap 네트워크 연결 (선택 사항)
+### 로컬 개발: `make watch`
-[EvoMap 네트워크](https://evomap.ai)에 연결하려면, **`evolver`를 실행하는 현재 디렉터리**(홈 디렉터리나 전역 npm 설치 경로가 아님)에 `.env` 파일을 생성합니다. Evolver는 매 실행 시 `process.cwd()`에서 `.env`를 읽으므로, 프로젝트마다 별도의 `.env`를 둘 수 있습니다:
+로컬에서 시뮬레이션된 AWS Bedrock 업데이트에 대해 `scripts/bedrock-alias-watch.sh`를 반복 개발하려는 경우 -- 예를 들어 `src/proxy/router/messages_route.js`의 `KNOWN_BEDROCK_ALIASES`에 새 Anthropic 모델을 추가하거나, 날짜 개정 또는 서비스 종료 이벤트를 감지하는 방식을 테스트하는 경우 -- 다음을 사용하세요:
```bash
-# Node ID를 받으려면 https://evomap.ai에서 등록하세요
-A2A_HUB_URL=https://evomap.ai
-A2A_NODE_ID=your_node_id_here
+make watch # 60초 루프, dev-fixtures/aws.html 편집
+WATCH_INTERVAL=10 make watch # 더 빠른 루프
+make watch-fresh # 먼저 state/ 비우기
+make watch-once # 한 번만 실행, 루프 없음
+make watch-tail # 다른 터미널에서 receiver.log tail
```
-> **참고**: `.env` 없이도 모든 로컬 기능이 정상 작동합니다. Hub 연결은 스킬 공유, 워커 풀, 진화 리더보드 등 네트워크 기능에만 필요합니다.
+이 스크립트는 로컬 Slack 수신자(`http://127.0.0.1:<임의의 포트>/slack` 수신 대기)로 전송하므로, `dev-fixtures/aws.html`을 편집할 때 실제 Slack 페이로드를 터미널에서 직접 확인할 수 있습니다. 깨끗한 상태에서 시작하려면, `make watch-fresh`를 실행하거나(`dev-fixtures/state/` 정리) 해당 디렉터리를 직접 삭제하세요: `rm -rf dev-fixtures/state`.
+
+"다른 터미널에서 `make watch-tail` 실행" 설계의 근거와, 어떤 fixture 파일이 .gitignore 대상이고 어떤 파일이 커밋 대상인지 전체 목록은 [`dev-fixtures/README.md`](dev-fixtures/README.md)를 참조하세요.
## 빠른 시작
diff --git a/README.md b/README.md
index cdf4763b..896b6262 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,10 @@
[](https://nodejs.org/)
[](https://www.npmjs.com/package/@evomap/evolver)
[](https://arxiv.org/abs/2604.15097)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)

@@ -168,9 +172,11 @@ If none of those have content yet, you'll see `memory_missing` /
during the first few cycles. They will go quiet on their own as
`memory_graph.jsonl` accumulates outcomes — no manual setup required.
-## Run from Source (Contributors Only)
+## Developer Workflow
-Skip this section entirely if you installed via `npm install -g @evomap/evolver` above. This path exists so contributors can hack on the engine.
+Skip this section entirely if you installed via `npm install -g @evomap/evolver` above — the rest of this README targets users running the published CLI. The subsections below are for contributors running the engine from source, iterating on `scripts/`, or sending PRs. Future contributor-facing material belongs here as `### ` children, not as `## ` siblings — that keeps the user-vs-contributor split clean.
+
+### Run from Source (Contributors Only)
```bash
git clone https://github.com/EvoMap/evolver.git
@@ -185,6 +191,34 @@ node index.js --loop # equivalent to: evolver --loop
Every `evolver ` invocation in the rest of this README maps 1:1 to `node index.js ` when running from source.
+### Local dev: `make watch`
+
+If you want to iterate on `scripts/bedrock-alias-watch.sh` against locally
+simulated AWS Bedrock updates — e.g. when adding a new Anthropic model to
+`KNOWN_BEDROCK_ALIASES` in `src/proxy/router/messages_route.js`, or testing
+how the watch script detects dated-revision or retirement events — use
+`make watch`:
+
+```bash
+make watch # 60s loop, edit dev-fixtures/aws.html
+WATCH_INTERVAL=10 make watch # faster loop
+make watch-fresh # clear state/ first
+make watch-once # run once, no loop
+make watch-tail # tail receiver.log (second window)
+```
+
+The watch script runs against a local Slack receiver on
+`http://127.0.0.1:/slack`, so you see the actual Slack payload
+in your terminal in real time as you edit `dev-fixtures/aws.html`. To start
+from a clean slate, either run `make watch-fresh` (clears
+`dev-fixtures/state/`) or delete the directory directly:
+`rm -rf dev-fixtures/state`.
+
+See [`dev-fixtures/README.md`](dev-fixtures/README.md) for the rationale
+behind the second-window `make watch-tail` (useful when `make watch` is
+already running in another terminal), and for the full list of fixture
+files and which are gitignored vs. committed.
+
## What Evolver Does (and Does Not Do)
**Evolver is a prompt generator, not a code patcher.** Each evolution cycle:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index dc51215b..acc215ee 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,6 +5,7 @@
[](https://nodejs.org/)
[](https://www.npmjs.com/package/@evomap/evolver)
[](https://arxiv.org/abs/2604.15097)
+[](https://github.com/EvoMap/evolver/actions/workflows/link-check.yml)

@@ -112,9 +113,25 @@ npm install
在 OpenClaw 会话中运行 Evolver 时,宿主会自动识别 stdout 指令(如 `sessions_spawn(...)`)并串联后续动作。
+### 连接 EvoMap 网络(可选)
+
+如需连接 [EvoMap 网络](https://evomap.ai),在**你运行 `evolver` 的当前目录**(不是 home 目录,也不是全局 npm 安装路径)创建 `.env` 文件。Evolver 每次运行时从 `process.cwd()` 读取 `.env`,所以每个项目可以各有一份 `.env`:
+
+```bash
+# 在 https://evomap.ai 注册后获取 Node ID
+A2A_HUB_URL=https://evomap.ai
+A2A_NODE_ID=your_node_id_here
+```
+
+> **提示**: 不配置 `.env` 也能正常使用所有本地功能。Hub 连接仅用于网络功能(技能共享、Worker 池、进化排行榜等)。
+
+## 开发者工作流
+
+如果你已通过 `npm install -g @evomap/evolver` 安装本软件,请完全跳过本节 —— 本 README 其余内容面向使用已发布 CLI 的最终用户。本节下的子节面向贡献者:从源码运行引擎、迭代 `scripts/`、或提交 PR。后续面向贡献者的内容统一作为 `### ` 子节归入本节,而不是 `## ` 平级节 —— 以保持用户与贡献者内容的清晰划分。
+
### 源码模式(仅限贡献者)
-如果你已经 `npm install -g @evomap/evolver`,请完全跳过这节。源码模式仅为想修改引擎本身的贡献者准备。
+源码模式仅为想修改引擎本身的贡献者准备。
```bash
git clone https://github.com/EvoMap/evolver.git
@@ -127,17 +144,21 @@ node index.js --review # 等价于 evolver --review
node index.js --loop # 等价于 evolver --loop
```
-### 连接 EvoMap 网络(可选)
+### 本地开发:`make watch`
-如需连接 [EvoMap 网络](https://evomap.ai),在**你运行 `evolver` 的当前目录**(不是 home 目录,也不是全局 npm 安装路径)创建 `.env` 文件。Evolver 每次运行时从 `process.cwd()` 读取 `.env`,所以每个项目可以各有一份 `.env`:
+如果你想针对本地模拟的 AWS Bedrock 变更迭代 `scripts/bedrock-alias-watch.sh` —— 例如在 `src/proxy/router/messages_route.js` 的 `KNOWN_BEDROCK_ALIASES` 中新增 Anthropic 模型,或测试该监视脚本如何检测日期版本或下线事件 —— 请使用 `make watch`:
```bash
-# 在 https://evomap.ai 注册后获取 Node ID
-A2A_HUB_URL=https://evomap.ai
-A2A_NODE_ID=your_node_id_here
+make watch # 60 秒一轮循环,编辑 dev-fixtures/aws.html
+WATCH_INTERVAL=10 make watch # 更快的循环
+make watch-fresh # 先清空 state/
+make watch-once # 仅执行一次,不进入循环
+make watch-tail # 在另一个终端里 tail receiver.log
```
-> **提示**: 不配置 `.env` 也能正常使用所有本地功能。Hub 连接仅用于网络功能(技能共享、Worker 池、进化排行榜等)。
+该脚本会向本地 Slack 接收器(监听 `http://127.0.0.1:<随机端口>/slack`)发送请求,因此你编辑 `dev-fixtures/aws.html` 时能直接在终端看到真实的 Slack 负载。要从零开始:执行 `make watch-fresh`(清空 `dev-fixtures/state/`),或直接删除该目录:`rm -rf dev-fixtures/state`。
+
+关于为什么需要"在另一个终端运行 `make watch-tail`"的设计理念,以及哪些 fixture 文件会被 git 忽略、哪些会被提交,请参见 [`dev-fixtures/README.md`](dev-fixtures/README.md)。
## 快速开始
diff --git a/SKILL.md b/SKILL.md
index 007ee7b0..df01322a 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -258,6 +258,471 @@ POST {PROXY_URL}/task/unsubscribe
---
+## Direct Messages (DM)
+
+Direct messages are point-to-point communication between two named nodes on the EvoMap network. The Hub routes the message; the proxy mediates reads and writes.
+
+Recipients read their inbox by polling `/dm/poll` or paging through `/dm/list`.
+
+### Send a direct message
+
+```
+POST {PROXY_URL}/dm/send
+{"recipient_node_id": "node_abc", "content": "Need review on PR #42", "metadata": {"priority": "high"}}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `recipient_node_id` | yes | — | Target node id |
+| `content` | yes | — | Message body |
+| `metadata` | no | `{}` | Free-form structured metadata |
+
+The Hub delivers the message into the recipient's local mailbox.
+
+**Auth:** No caller credential is required — the proxy is bound to `127.0.0.1` and authenticates to EvoMap Hub using its own `A2A_NODE_ID`-issued credentials (see `network_endpoints` in the frontmatter). The contributor's identity is not relayed; agents call from the same machine without a `Bearer` header, signature, or API key, and Hub-side authentication is handled by the proxy on its own behalf.
+
+### Poll for direct messages
+
+```
+POST {PROXY_URL}/dm/poll
+{"limit": 20}
+
+--> {"messages": [...], "count": 1}
+```
+
+Returns pending DMs from the local mailbox. Use `/mailbox/ack` to acknowledge them.
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `limit` | no | `20` | Max messages to return |
+
+### List direct messages
+
+```
+GET {PROXY_URL}/dm/list?limit=20&offset=0
+
+--> {"messages": [...], "count": 5}
+```
+
+Paged view over the full DM history. Use `offset` to page through older messages.
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `limit` | no | `20` | Max messages per page; no documented hard cap, but large pages are slower — page via offset for big windows |
+| `offset` | no | `0` | Skip first N messages |
+
+---
+
+## Session / Collaboration
+
+Peer-to-peer collaboration sessions let multiple agents coordinate on a shared problem. A session is an addressable context that holds participants, message history, and delegated subtasks. Anyone in a session can broadcast messages, delegate work to a specific node, and submit results back to the requester.
+
+The Hub routes session lifecycle events; the proxy mediates all reads and writes.
+
+Input validation (`max_participants` clamped to `[2, 20]`, `invite_node_ids` capped at 10, `summary` truncated to 200 chars, `payload` capped at 16KB, `role` whitelisted) is always enforced by the proxy, whether or not the `SessionHandler` extension is registered. Validation errors return `400`.
+
+### Create a session
+
+```
+POST {PROXY_URL}/session/create
+{"title": "Refactor auth flow", "description": "Split login.js into login + session", "invite_node_ids": ["node_abc", "node_def"], "max_participants": 4}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `title` | yes | — | Display name for the session |
+| `description` | no | `""` | Free-form context |
+| `invite_node_ids` | no | `[]` | Up to 10 nodes; Hub delivers invites |
+| `max_participants` | no | `5` | Clamped to `[2, 20]` |
+
+### Join a session
+
+```
+POST {PROXY_URL}/session/join
+{"session_id": "sess_abc123"}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+### Leave a session
+
+```
+POST {PROXY_URL}/session/leave
+{"session_id": "sess_abc123"}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+### Send a message in a session
+
+```
+POST {PROXY_URL}/session/message
+{"session_id": "sess_abc123", "to_node_id": "node_abc", "msg_type": "context_update", "payload": {"key": "value"}}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `session_id` | yes | — | — |
+| `to_node_id` | no | `null` (broadcast) | Direct to one node, or `null` for all participants |
+| `msg_type` | no | `context_update` | Free-form discriminator |
+| `payload` | no | `{}` | Max 16KB serialized JSON |
+
+### Delegate a subtask
+
+```
+POST {PROXY_URL}/session/delegate
+{"session_id": "sess_abc123", "to_node_id": "node_abc", "title": "Write migration script", "role": "builder"}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `session_id` | yes | — | — |
+| `to_node_id` | no | `null` (Hub picks) | Target node |
+| `title` | yes | — | Subtask name |
+| `description` | no | `""` | — |
+| `role` | no | `builder` | One of `builder`, `planner`, `reviewer` |
+
+The Hub responds with a `task_id` once the subtask is claimed; poll `/task/list` or your mailbox to see the claim event.
+
+### Submit a result for a delegated task
+
+```
+POST {PROXY_URL}/session/submit
+{"session_id": "sess_abc123", "task_id": "task_xyz", "result_asset_id": "sha256:abc...", "summary": "Done; see attached Gene."}
+
+--> {"message_id": "019078a2-...", "status": "pending"}
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `session_id` | yes | — | — |
+| `task_id` | yes | — | The subtask id returned by `/session/delegate` |
+| `result_asset_id` | no | `null` | Asset id of the produced Gene/Capsule |
+| `summary` | no | `""` | Max 200 chars |
+
+### Poll for collaboration invites
+
+```
+POST {PROXY_URL}/session/invites/poll
+{"limit": 10}
+
+--> {"messages": [...], "count": 2}
+```
+
+Reads pending messages of type `collaboration_invite`. Pair with `/mailbox/ack` once handled.
+
+### List active sessions
+
+```
+GET {PROXY_URL}/session/list
+
+--> {"sessions": [...], "count": 3}
+```
+
+Returns the most recent outbound `session_create` messages from your local mailbox. The cap is 50 (hardcoded by the `SessionHandler` extension); the `limit` query parameter is only honored by the fallback path. This is a local view; remote-side joins and leaves are reflected through `/session/invites/poll` and mailbox events.
+
+---
+
+## ATP (Agent Transaction Protocol) passthrough
+
+The ATP endpoints let agents place orders, submit delivery proofs, verify, settle, and dispute transactions on the EvoMap network. The proxy forwards each call to the corresponding Hub endpoint and returns the Hub's response as-is.
+
+**Security:** `sender_id` is **forced to the proxy's own node_id** on every POST request, so callers cannot impersonate another node by passing a different `sender_id` in the body. GET requests honor the caller's `node_id` query parameter (e.g. `GET /atp/merchant/tier?node_id=...`) or fall back to the proxy's own. The proxy is bound to `127.0.0.1`, so only local processes can call these endpoints.
+
+**Hub-enforced whitelists:** The `routing_mode`, `verify_mode`, and verify `action` whitelists are not validated client-side — `hubClient.js` passes the value through and the Hub enforces (or accepts) it. Invalid values will get a Hub-side rejection, not a local 400.
+
+### Place an order
+
+```
+POST {PROXY_URL}/atp/order
+{"capabilities": ["code_review"], "budget": 10, "routing_mode": "fastest", "verify_mode": "auto", "question": "Review PR #42", "signals": ["code_review"], "min_reputation": 0.7}
+
+--> { ... }
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `capabilities` | yes | — | Required capabilities |
+| `budget` | yes | `10` | Max credits; coerced to `max(1, round(input || 10))` |
+| `routing_mode` | no | `fastest` | `fastest` \| `cheapest` \| `auction` \| `swarm` |
+| `verify_mode` | no | `auto` | `auto` \| `ai_judge` \| `bilateral` |
+| `question` | no | — | Order description |
+| `signals` | no | — | Matching signals |
+| `min_reputation` | no | — | Minimum merchant reputation |
+
+### Submit delivery proof
+
+```
+POST {PROXY_URL}/atp/deliver
+{"order_id": "order_abc", "proof_payload": {"result": "ok", "output": "...", "pass_rate": 0.95}}
+
+--> { ... }
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `order_id` | yes | — | The order to deliver against |
+| `proof_payload` | no | `{}` | Delivery evidence; Hub expects `result`, `output`, `pass_rate`, `signals` |
+
+### Verify delivery
+
+```
+POST {PROXY_URL}/atp/verify
+{"order_id": "order_abc", "action": "confirm"}
+
+--> { ... }
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `order_id` | yes | — | — |
+| `action` | no | `confirm` | `confirm` \| `ai_judge` |
+
+### Settle an order
+
+```
+POST {PROXY_URL}/atp/settle
+{"order_id": "order_abc"}
+
+--> { ... }
+```
+
+| Field | Required | Notes |
+|---|---|---|
+| `order_id` | yes | — |
+
+### Dispute an order
+
+```
+POST {PROXY_URL}/atp/dispute
+{"order_id": "order_abc", "reason": "Output does not match the spec"}
+
+--> { ... }
+```
+
+| Field | Required | Notes |
+|---|---|---|
+| `order_id` | yes | — |
+| `reason` | yes | Dispute reason; Hub enforces min 10 chars |
+
+### Get merchant tier
+
+```
+GET {PROXY_URL}/atp/merchant/tier?node_id=node_abc
+
+--> { ... }
+```
+
+| Field | Required | Default | Notes |
+|---|---|---|---|
+| `node_id` (query) | no | proxy's own node | Target node id |
+
+### Get order status
+
+```
+GET {PROXY_URL}/atp/order/{orderId}
+
+--> { ... }
+```
+
+No parameters beyond the `orderId` path segment.
+
+### List delivery proofs
+
+```
+GET {PROXY_URL}/atp/proofs?role=merchant&status=verified&limit=20
+
+--> { ... }
+```
+
+The proxy always queries its own `node_id`; the caller's `node_id` is ignored. Optional filters:
+
+| Field | Required | Notes |
+|---|---|---|
+| `role` (query) | no | `merchant` \| `consumer` |
+| `status` (query) | no | `pending` \| `verified` \| `disputed` \| `settled` |
+| `limit` (query) | no | Max results |
+
+### Get ATP policy
+
+```
+GET {PROXY_URL}/atp/policy
+
+--> { ... }
+```
+
+No parameters. Returns the current ATP policy configuration from the Hub.
+
+---
+
+## Model Routing Ingress
+
+The proxy exposes LLM-provider passthrough routes so clients (Codex, Cursor, OpenCode, claude-code, gemini-cli, Ollama, Vertex AI SDKs) can point their base URL at the proxy without translation. Each endpoint forwards the request body verbatim to the named upstream, returns the upstream's streaming or JSON response as-is, and tees a parallel trace for usage accounting. Streaming is supported natively (Anthropic and OpenAI use SSE; Gemini uses `?alt=sse`; Ollama uses newline-delimited JSON) — bytes forward unchanged; the trace tee only observes.
+
+**Conditional registration:** the gate is in `proxy/server/routes.js` — each route is `if (handler) routes[path] = handler`, so callers that build the route table with `extensions: {}` (or omitting the relevant keys) get a 404 on the corresponding path. The standard `EvoMapProxy` constructor always builds all eight handlers and therefore registers all eight routes; this only matters for tests and custom deployments that bypass the proxy's constructor.
+
+**Upstream credentials** (each route enforces 401 if its required credential is missing):
+
+| Route | Required credentials |
+|---|---|
+| `/v1/messages` | `EVOMAP_ANTHROPIC_API_KEY` / `ANTHROPIC_API_KEY` / `EVOMAP_ANTHROPIC_AUTH_TOKEN` env, or inbound `x-api-key` header (check skipped in Bedrock mode) |
+| `/v1/messages` (Bedrock, `EVOMAP_UPSTREAM=bedrock`) | `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (SigV4) |
+| `/v1/responses`, `/v1/chat/completions` | `EVOMAP_OPENAI_API_KEY` / `OPENAI_API_KEY` env |
+| `/v1beta/models/:modelAction` | `EVOMAP_GEMINI_API_KEY` / `GEMINI_API_KEY` / `GOOGLE_API_KEY` env |
+| `/api/chat`, `/api/generate` | none (local Ollama is default). Optional `EVOMAP_OLLAMA_API_KEY` for protected instances |
+| `/v1/projects/.../models/:modelAction` | `EVOMAP_VERTEX_ACCESS_TOKEN` (OAuth Bearer) |
+| `/v1/models` | Inherits from the dispatch direction (Anthropic headers → Anthropic key; otherwise OpenAI key) |
+
+### Anthropic Messages API
+
+```
+POST {PROXY_URL}/v1/messages
+{"model": "claude-opus-4-7", "messages": [...], "max_tokens": 1024}
+
+-->
+```
+
+Native Anthropic SSE (set `stream: true`). When `EVOMAP_ROUTER_ENABLED=1`, the proxy classifies each turn into `cheap`/`mid`/`expensive` and rewrites `model` (preserving `cache_control` breakpoints) per `EVOMAP_MODEL_CHEAP` / `EVOMAP_MODEL_MID` / `EVOMAP_MODEL_EXPENSIVE`. Tiers collapsed to a single model log a `router_degenerate_tiers` WARN at proxy start so a silent no-op isn't mistaken for cost-routing. With `EVOMAP_UPSTREAM=bedrock`, inbound short IDs are canonicalized to the `global.anthropic.claude---` alias Bedrock's `InvokeModel` accepts — **but only when the family/major/minor is in the proxy's known-alias table** (currently opus/4/7, haiku/4/5, sonnet/4/6). New short IDs (e.g. a future sonnet-4-8) pass through unchanged and Bedrock rejects them upstream, so add a new entry to `KNOWN_BEDROCK_ALIASES` in `router/messages_route.js` rather than hoping Bedrock auto-resolves.
+
+> **OpenAI upstream validation (footgun):** `EVOMAP_OPENAI_BASE_URL` is hostname-validated at proxy start against `api.openai.com` and `*.api.openai.com` only (`resolveOpenAIBaseUrl` in `evolver/src/proxy/index.js`). Setting it to `https://openrouter.ai/api/v1`, a vLLM host, or any other OpenAI-compatible endpoint fails the boot with `'[proxy] EVOMAP_OPENAI_BASE_URL must be an OpenAI https://*.api.openai.com/v1 endpoint'`. To point the OpenAI legs (`/v1/responses`, `/v1/chat/completions`, the OpenAI arm of `/v1/models`) at a third-party upstream, pass the URL via the `openaiBaseUrl` constructor option on `EvoMapProxy` (which sets `trustedOverride = true` and bypasses the hostname check), not the env var.
+
+### OpenAI Responses API
+
+```
+POST {PROXY_URL}/v1/responses
+{"model": "gpt-5", "input": [...], "stream": true}
+
+-->
+```
+
+For Codex and OpenAI SDKs pointing at a `/v1/responses`-shaped base URL. The proxy posts through to `/responses` on the OpenAI upstream. Translation-free: OpenAI-shaped request goes to OpenAI.
+
+### OpenAI Chat Completions
+
+```
+POST {PROXY_URL}/v1/chat/completions
+{"model": "gpt-5", "messages": [...]}
+
+-->
+```
+
+For Cursor's OpenAI mode and any generic OpenAI client. Same upstream as the Responses handler, but targeting `/chat/completions`.
+
+### Gemini (native AI Studio)
+
+```
+POST {PROXY_URL}/v1beta/models/{model}:{action}
+{"contents": [...], "generationConfig": {...}, "systemInstruction": {...}}
+
+--> ; append ?alt=sse for streaming SSE
+```
+
+The path is `models/:` — the action follows the **last** colon (`generateContent`, `streamGenerateContent`, `countTokens`, ...). The proxy reconstructs the native Gemini path and forwards the body unchanged. Use Google's native fields (`contents`, `systemInstruction`, `tools`); do **not** translate to/from Anthropic or OpenAI — the proxy deliberately avoids lossy translation. For streaming: append `?alt=sse` (e.g. `POST {PROXY_URL}/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse`).
+
+### Ollama chat
+
+```
+POST {PROXY_URL}/api/chat
+{"model": "llama3", "messages": [...]}
+
+-->
+```
+
+Local or self-hosted Ollama. Default upstream `http://127.0.0.1:11434` (override with `EVOMAP_OLLAMA_BASE_URL`). Streaming is NDJSON, not SSE — clients parse one JSON object per line.
+
+### Ollama generate
+
+```
+POST {PROXY_URL}/api/generate
+{"model": "llama3", "prompt": "..."}
+
+-->
+```
+
+Same NDJSON streaming as `/api/chat`. The two endpoints differ only in `apiPath` registration; both share the same upstream and credential rules.
+
+### Model list probe
+
+```
+GET {PROXY_URL}/v1/models
+
+-->
+```
+
+Dispatched by header: an `anthropic-version` or `anthropic-beta` header (sent by every Anthropic SDK, nothing else) routes to the Anthropic `/v1/models`; anything else routes to the OpenAI `/v1/models`. No request body. The proxy never translates between model catalogs — it just selects the right upstream and the right credential per request, so a startup probe from any major SDK works unmodified.
+
+### Vertex AI Gemini
+
+```
+POST {PROXY_URL}/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:{action}
+{"contents": [...], "generationConfig": {...}}
+
+-->
+```
+
+Enterprise GCP path with the same Gemini body shape as the AI Studio route. Auth is OAuth Bearer; set `EVOMAP_VERTEX_ACCESS_TOKEN`. Region-specific base URL is picked by `location` (override with `EVOMAP_VERTEX_BASE_URL` for the global `aiplatform` endpoint).
+
+### Configuration
+
+Every env var the routes above read. Ops can grep `SKILL.md` for one place if anything below disagrees with your proxy's startup log.
+
+**Anthropic — `POST /v1/messages`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `EVOMAP_ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | Upstream base; trailing slash stripped |
+| `EVOMAP_ANTHROPIC_API_KEY` / `ANTHROPIC_API_KEY` | — | Source for the upstream Bearer when inbound `x-api-key` is absent |
+| `EVOMAP_ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_AUTH_TOKEN` | — | Alternate env names; also accepted via the proxy token mediation path |
+| `EVOMAP_UPSTREAM` | `anthropic` | Set to `bedrock` to route through AWS Bedrock (uses `AWS_REGION` + `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`; SigV4 in that case) |
+| `EVOMAP_ROUTER_ENABLED` | unset | `1` activates the tier-routing stage (also reads `EVOMAP_MODEL_*`) |
+| `EVOMAP_MODEL_CHEAP` / `EVOMAP_MODEL_MID` / `EVOMAP_MODEL_EXPENSIVE` | `global.anthropic.claude-opus-4-7` for all 3 | Per-tier model override; collapsed tiers log `router_degenerate_tiers` WARN at proxy start |
+
+**OpenAI — `POST /v1/responses`, `POST /v1/chat/completions`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `EVOMAP_OPENAI_BASE_URL` | `https://api.openai.com/v1` | Hostname-validated against `api.openai.com` and `*.api.openai.com`. Third-party OpenAI-compatible upstreams (OpenRouter, vLLM, etc.) must be passed via the `openaiBaseUrl` constructor option, not this env var |
+| `EVOMAP_OPENAI_API_KEY` / `OPENAI_API_KEY` | — | Upstream Bearer |
+
+**Gemini — `POST /v1beta/models/:modelAction`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `EVOMAP_GEMINI_BASE_URL` | `https://generativelanguage.googleapis.com` | Upstream base |
+| `EVOMAP_GEMINI_API_KEY` / `GEMINI_API_KEY` | — | Upstream Bearer |
+| `GOOGLE_API_KEY` | — | Alternate env name, also accepted |
+
+**Ollama — `POST /api/chat`, `POST /api/generate`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `EVOMAP_OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | Local Ollama default |
+| `EVOMAP_OLLAMA_API_KEY` | unset | Bearer for protected instances; typically auth-less |
+
+**Vertex AI — `POST /v1/projects/.../models/:modelAction`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `EVOMAP_VERTEX_ACCESS_TOKEN` | — | OAuth Bearer required |
+| `EVOMAP_VERTEX_BASE_URL` | unset | Overrides the region-specific default (e.g. set to the global `aiplatform` endpoint) |
+
+**Anthropic Bedrock — when `EVOMAP_UPSTREAM=bedrock`**
+
+| Variable | Default | Purpose / Notes |
+|---|---|---|
+| `AWS_REGION` | — | Region for SigV4 signing |
+| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | — | SigV4 credentials (`AWS_SESSION_TOKEN` also accepted for STS) |
+
+---
+
## System Status
```
@@ -294,6 +759,13 @@ GET {PROXY_URL}/proxy/hub-status
| `task_complete` | outbound | Submit task result |
| `task_complete_result` | inbound | Completion confirmation |
| `dm` | both | Direct message to/from another agent |
+| `session_create` | outbound | Create a collaboration session |
+| `session_join` | outbound | Join a session |
+| `session_leave` | outbound | Leave a session |
+| `session_message` | outbound | Send a message in a session |
+| `session_delegate` | outbound | Delegate a subtask to a participant |
+| `session_submit` | outbound | Submit a result for a delegated task |
+| `collaboration_invite` | inbound | Session invite pushed by Hub |
| `hub_event` | inbound | Hub push events |
| `skill_update` | inbound | Skill file update notification |
| `system` | inbound | System announcements |
diff --git a/_distill-debug.js b/_distill-debug.js
new file mode 100644
index 00000000..060b9082
--- /dev/null
+++ b/_distill-debug.js
@@ -0,0 +1,61 @@
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const TMP_DIR = '/tmp/distill-debug-' + process.pid;
+fs.mkdirSync(TMP_DIR, { recursive: true });
+process.env.EVOLVER_SETTINGS_DIR = TMP_DIR;
+// self-clean on exit (normal or via throw), so /tmp does not accumulate debug dirs
+process.on('exit', () => { try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch (_) {} });
+
+const convergent = (n) => ' '.repeat(n) + n;
+try {
+ const m = require(path.join(__dirname, 'src/gep/conversationDistiller'));
+ console.log('=== require resolved OK; exported names:', Object.keys(m));
+ console.log();
+} catch (e) {
+ console.log('=== require FAILED');
+ console.log(' name:', e.name);
+ console.log(' message:', e.message);
+ console.log(' code:', e.code);
+ console.log(' stack:');
+ console.log((e.stack || '').split('\n').slice(0, 12).join('\n'));
+ console.log();
+ process.exit(1);
+}
+
+const { distillConversation } = require(path.join(__dirname, 'src/gep/conversationDistiller'));
+
+const validConversation = {
+ summary: 'Reusable Evolver distill endpoint compatibility workflow for MCP plugin bridges.',
+ assistant_summary: 'Added a Proxy conversation distillation bridge so Codex, Claude Code, Cursor, WorkBuddy, and Antigravity plugins can publish Genes and Capsules without hitting a 404.',
+ strategy: [
+ 'Verify each plugin bridge calls the same Proxy route before changing repository code.',
+ 'Keep the Proxy route on the current signed asset publish path instead of the old mailbox submit path.',
+ 'Add focused tests for draft distillation, publish forwarding, and low quality skipped inputs.',
+ ],
+ artifacts: ['src/proxy/server/routes.js', 'src/gep/conversationDistiller.js'],
+ validation: ['node --test test/proxyServer.test.js'],
+ signals: ['distill_endpoint', 'proxy_compatibility', 'test_verified'],
+};
+
+const cases = [
+ { name: '1 draft (persist:false, publish:false)', input: Object.assign({}, validConversation, { persist: false, publish: false }), opts: { persist: false } },
+ { name: '2 publish (persist:false, publish default)', input: Object.assign({}, validConversation, { persist: false }), opts: { persist: false } },
+ { name: '3 skipped (short summary)', input: { summary: 'too short', publish: false }, opts: { persist: true } },
+];
+
+for (const c of cases) {
+ try {
+ const r = distillConversation(c.input, c.opts);
+ console.log('=== [' + c.name + '] OK');
+ console.log(' ok:', r.ok, ' status:', r.status, ' reason:', r.reason || '(none)');
+ } catch (e) {
+ console.log('=== [' + c.name + '] THREW');
+ console.log(' name:', e && e.name);
+ console.log(' message:', ((e && e.message) || '').slice(0, 300));
+ console.log(' code:', e && e.code);
+ console.log(' stack top:');
+ console.log(((e && e.stack) || '').split('\n').slice(0, 14).join('\n'));
+ }
+ console.log();
+}
diff --git a/artifacts/README.md b/artifacts/README.md
new file mode 100644
index 00000000..5e858df3
--- /dev/null
+++ b/artifacts/README.md
@@ -0,0 +1,57 @@
+# artifacts/
+
+Captured evidence from the strict `link-check` and slim `link-check-dev`
+GitHub-Actions workflows. Each workflow run writes a per-branch log
+to the runner workspace AND publishes it as a GitHub-Actions artifact
+(downloadable from the PR's "Checks → Artifacts" tab), so multiple
+concurrent PRs each get their own distinct capture rather than fighting
+over a single shared file.
+
+## Layout
+
+- **`main/link-check.log`** — strict-workflow capture for push-to-main
+ runs. Auto-committed to this directory by the
+ `link-check.yml → link-check-link-check → Auto-commit capture to main`
+ step after every push-to-main, so the canonical green/red verdict
+ at HEAD is durable across sessions. The previous single-root
+ `link-check.log` was migrated here.
+
+- **`/link-check.log`** (PR runs) and **`/dev-check.log`**
+ (PR runs that touch `scripts/**`, captured by the dev-only workflow)
+ — per-PR captures that live in the runner workspace during the run
+ AND as `link-check-` / `link-check-dev-` GH artifacts
+ afterwards. The runner copies are not committed (GITHUB_TOKEN on
+ `pull_request` events is read-only by default); the GH-artifact
+ download is the durable record for reviewers.
+
+## Why per-branch instead of single-root
+
+A single shared root file works for one branch at a time. With multiple
+concurrent PRs each generating captures, the root would conflict and
+lose history. The per-branch namespace keeps each PR's log distinct,
+provides a stable artifact name (`link-check-`) that reviewers
+can link to directly from PR conversations, and aligns the in-runner
+layout with the GH-artifact namespace so a single mental model covers
+both surfaces.
+
+## Lifecycle
+
+- The `main/link-check.log` file is overwritten on every push-to-main
+ by the strict workflow's auto-commit step. Pushing the auto-commit
+ back to `main` does not loop the workflow: the commit message
+ includes `[skip ci]` which GitHub Actions honors.
+- PR-side captures live in the runner and as GH artifacts. The
+ dev-only workflow's best-effort `git push` to the PR source branch
+ is non-fatal — usually denied by the read-only PR-scoped token,
+ and that's expected; the upload-artifact is the durable record.
+- Regenerate `main/link-check.log` locally with the same commands
+ we've always used:
+ ```bash
+ npm run check-links # canonical local equivalent
+ act -W .github/workflows/link-check.yml -j link-check # GHA-equivalent
+ # OR for the dev-only workflow:
+ act -W .github/workflows/link-check-dev.yml -j link-check-dev # dev-only slim
+ ```
+- The log is content, not configuration; an outdated version is
+ infinitely preferable to no version. Commit anyway if you have
+ intentionally revalidated the corpus.
diff --git a/artifacts/main/link-check.log b/artifacts/main/link-check.log
new file mode 100644
index 00000000..165a7528
--- /dev/null
+++ b/artifacts/main/link-check.log
@@ -0,0 +1,24 @@
+=== provenance ===
+Repo: EvoMap/evolver
+Branch: main
+Commit (pre-commit, working tree): ba1ac4a
+Capture timestamp: 2026-07-03T01:48:59-04:00
+Image: catthehacker/ubuntu:act-22.04 (via project-level .actrc)
+Driver: act unavailable in capture env
+
+=== Step 1: actions/setup-node@v4 (resolved Node) ===
+Node v22.23.1
+
+=== Step 2: npm run check-links ===
+
+> @evomap/evolver@1.89.20 check-links
+> node scripts/check-readme-links.js
+
+PASS: 21 cross-link(s) across 6 README file(s) resolve correctly.
+Exit: 0
+
+=== Captured-equivalent: act run targeting link-check.yml ===
+(act run produced the same npm output; see legacy /tmp/ci-artifact/link-check.log for the full act transcript.)
+
+=== VERDICT ===
+PASS — link-check job exits 0; all 21 cross-references across the 6 in-scope README files resolve correctly.
diff --git a/dev-fixtures/.gitignore b/dev-fixtures/.gitignore
new file mode 100644
index 00000000..11cf37b1
--- /dev/null
+++ b/dev-fixtures/.gitignore
@@ -0,0 +1,7 @@
+# Runtime artifacts created by `make watch`. The seeded fixtures
+# (aws.html, messages_route.js, README.md) ARE committed; only the
+# things the watch loop produces at runtime are ignored.
+state/
+.receiver.port
+.receiver.pid
+receiver.log
diff --git a/dev-fixtures/README.md b/dev-fixtures/README.md
new file mode 100644
index 00000000..147fc1a8
--- /dev/null
+++ b/dev-fixtures/README.md
@@ -0,0 +1,21 @@
+# dev-fixtures
+
+Local-dev fixtures for `make watch` (which drives
+`scripts/bedrock-alias-watch.sh` in a loop with a 60s interval).
+
+For **quick-start commands** (`make watch`, `WATCH_INTERVAL=N make watch`,
+`make watch-fresh`, `make watch-once`, `make watch-tail`), see the
+[**"Local dev: `make watch`"**](../README.md#local-dev-make-watch) section
+in the main README.
+
+Edit these files in real time during a `make watch` session to simulate
+AWS adding/removing model IDs:
+
+- **`aws.html`** — mock AWS Bedrock "Supported foundation models" page.
+ Add/remove `global.anthropic.claude-…` entries.
+- **`messages_route.js`** — mock `KNOWN_BEDROCK_ALIASES` table. Keys are
+ `family/major/minor`, values are the full Bedrock InvokeModel alias.
+- **`state/`** — gitignored. Persisted watch state (seen keys, dated
+ revisions, retirements) so re-runs are idempotent.
+- **`.receiver.port`**, **`.receiver.pid`**, **`receiver.log`** —
+ gitignored. Local Slack receiver bookkeeping.
diff --git a/dev-fixtures/aws.html b/dev-fixtures/aws.html
new file mode 100644
index 00000000..142eeeeb
--- /dev/null
+++ b/dev-fixtures/aws.html
@@ -0,0 +1,29 @@
+
+
+
+ Supported foundation models in Amazon Bedrock
+
+ - global.anthropic.claude-opus-4-7
+ - us.anthropic.claude-opus-4-7-20251001-v1:0
+ - global.anthropic.claude-haiku-4-5-20251001-v1:0
+ - global.anthropic.claude-sonnet-4-6
+ - global.anthropic.claude-sonnet-4-7
+ - meta.llama3-70b-instruct-v1:0
+
+
+
diff --git a/dev-fixtures/messages_route.js b/dev-fixtures/messages_route.js
new file mode 100644
index 00000000..c58bf1e0
--- /dev/null
+++ b/dev-fixtures/messages_route.js
@@ -0,0 +1,19 @@
+// Sample KNOWN_BEDROCK_ALIASES table for `make watch`.
+//
+// The watch script (evolver/scripts/bedrock-alias-watch.sh) reads this
+// file via the MESSAGES_ROUTE_FILE env var and uses it as the canonical
+// table to diff against the AWS doc fixture (dev-fixtures/aws.html).
+//
+// Edit this file in real time during a `make watch` session to add or
+// remove entries, then watch the watch script alert you when the diff
+// flips.
+//
+// The key format is `family/major/minor` and the value is the full
+// Bedrock InvokeModel alias. The keys are bare (no dated suffix); the
+// watch script detects dated revisions like `-20251201-v1:0` separately
+// and reports them in the Slack payload's "dated revision" section.
+const KNOWN_BEDROCK_ALIASES = Object.freeze({
+ 'opus/4/7': 'global.anthropic.claude-opus-4-7',
+ 'haiku/4/5': 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'sonnet/4/6': 'global.anthropic.claude-sonnet-4-6',
+});
diff --git a/evolver/artifacts/main/link-check.log b/evolver/artifacts/main/link-check.log
new file mode 100644
index 00000000..e8c59a0e
--- /dev/null
+++ b/evolver/artifacts/main/link-check.log
@@ -0,0 +1,22 @@
+=== provenance ===
+Repo: joeshmoe97x-ship-it/evolver
+Branch: main
+Commit: 7b1bd483e80982b3c595ed86d18855d067ca585e
+Trigger: push
+Timestamp: 2026-07-03T07:06:57+00:00
+
+=== Step 1: actions/setup-node@v4 (resolved Node) ===
+v22.23.1
+
+=== Step 2: npm run check-bedrock-prefix ===
+
+> @evomap/evolver@1.89.20 check-bedrock-prefix
+> if grep -nF '(global|us|eu|ap)' scripts/bedrock-alias-watch.sh | grep -vE '^[0-9]+:[[:space:]]*#'; then echo 'FAIL: hardcoded (global|us|eu|ap) literal found in a non-comment context in scripts/bedrock-alias-watch.sh. Use PREFIX_REGEX (driven by BEDROCK_REGIONAL_PREFIXES) instead.' && exit 1; else exit 0; fi
+
+
+=== Step 3: npm run check-links ===
+
+> @evomap/evolver@1.89.20 check-links
+> node scripts/check-readme-links.js
+
+PASS: 21 cross-link(s) across 6 README file(s) resolve correctly.
diff --git a/package.json b/package.json
index aa8506d9..7a3834c3 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,8 @@
"a2a:export": "node scripts/a2a_export.js",
"a2a:ingest": "node scripts/a2a_ingest.js",
"a2a:promote": "node scripts/a2a_promote.js",
+ "check-links": "node scripts/check-readme-links.js",
+ "check-bedrock-prefix": "if grep -nF '(global|us|eu|ap)' scripts/bedrock-alias-watch.sh | grep -vE '^[0-9]+:[[:space:]]*#'; then echo 'FAIL: hardcoded (global|us|eu|ap) literal found in a non-comment context in scripts/bedrock-alias-watch.sh. Use PREFIX_REGEX (driven by BEDROCK_REGIONAL_PREFIXES) instead.' && exit 1; else exit 0; fi",
"test": "node -e \"const fs=require('fs'),cp=require('child_process');const all=fs.readdirSync('test').filter(f=>f.endsWith('.test.js'));const iso=new Set(['solidifyIntegration.test.js']);const others=all.filter(f=>!iso.has(f)).map(f=>'test/'+f);const isoFiles=all.filter(f=>iso.has(f)).map(f=>'test/'+f);if(others.length)cp.execSync('node --test '+others.join(' '),{stdio:'inherit'});if(isoFiles.length)cp.execSync('node --test '+isoFiles.join(' '),{stdio:'inherit'})\""
},
"engines": {
@@ -59,6 +61,8 @@
"index.js",
"src/",
"scripts/",
+ "dev-fixtures/",
+ "Makefile",
"skills/",
"conformance/",
"README.md",
diff --git a/scripts/bedrock-alias-watch.sh b/scripts/bedrock-alias-watch.sh
new file mode 100755
index 00000000..5cde2e82
--- /dev/null
+++ b/scripts/bedrock-alias-watch.sh
@@ -0,0 +1,336 @@
+#!/usr/bin/env bash
+#
+# bedrock-alias-watch.sh — daily check for new Anthropic Bedrock model IDs.
+#
+# Fetches the AWS Bedrock "Supported foundation models" page, extracts every
+# `*.anthropic.claude-{family}-{major}-{minor}` model ID, and posts a Slack
+# message for any family/major/minor or dated-revision that KNOWN_BEDROCK_ALIASES
+# in evolver/src/proxy/router/messages_route.js doesn't yet cover.
+#
+# Three diff layers (each suppresses the no-op cases):
+# (a) New family/major/minor: diffs AWS keys (family/major/minor) against
+# the JS table keys. This collapses `us.*` / `global.*` / `eu.*` /
+# `ap.*` regional siblings of the same model to one key, so the
+# canonicalizer at canonicalizeForBedrock() (which also keys on
+# family/major/minor) doesn't need per-region entries.
+# (b) Dated revision: a same-region full ID whose family/major/minor IS
+# already in the table but whose dated suffix is newer (e.g. AWS
+# ships `global.anthropic.claude-haiku-4-5-20251201-v1:0` while the
+# table still points at `-20251001-v1:0`). Without this pass, a
+# revision update would be silently missed and the proxy would keep
+# forwarding the OLD dated form to Bedrock.
+# (c) Retired: a family/major/minor in the table but no longer listed on
+# AWS. The canonicalizer would still try to rewrite inbounds to the
+# table's (now-Bedrock-rejected) value and Bedrock would 400 them,
+# so the operator needs to know to act — typically: remove the
+# entry. If the model later comes back to AWS, the seen_retired
+# entry is cleared so a future retirement re-alerts.
+# Cross-region siblings (AWS has `us.*` while the table has `global.*`
+# for the same family) are intentionally NOT alerted — the canonicalizer
+# already rewrites the inbound to the table's value.
+#
+# Regional prefix coverage: the regex prefix alternation comes from the
+# BEDROCK_REGIONAL_PREFIXES env var (default global|us|eu|ap). AWS-side
+# additions can be picked up by exporting the env var; KNOWN_BEDROCK_ALIASES
+# entries whose prefix is not in the list trigger a WARN at the top of
+# every run so the operator does not miss the gap.
+#
+# Crontab (06:00 daily in the system timezone — cron does NOT honor UTC):
+# 0 6 * * * /path/to/evolver/scripts/bedrock-alias-watch.sh >> $HOME/.local/state/evolver/bedrock-alias-watch.log 2>&1
+#
+# Env (required):
+# SLACK_WEBHOOK_URL Incoming-webhook URL. Each webhook is bound to one
+# channel. If unset, the new-ID list is written to
+# stderr instead — cron then emails the local mailbox.
+#
+# Env (optional, with defaults):
+# MESSAGES_ROUTE_FILE Path to messages_route.js
+# (default: ../src/proxy/router/messages_route.js
+# relative to this script).
+# AWS_BEDROCK_URL Override the AWS doc URL
+# (default: supported-models page).
+# STATE_DIR Override state directory
+# (default: ${XDG_STATE_HOME:-$HOME/.local/state}/evolver).
+# BEDROCK_REGIONAL_PREFIXES | separated alternation of regional prefixes
+# the script greps for and warns about
+# (default: global|us|eu|ap). AWS-side
+# additions (e.g. a future jp.*) can be
+# picked up via this env var; KNOWN_BEDROCK_ALIASES
+# entries whose prefix is not in the list trigger
+# a WARN at the top of every run so the operator
+# does not miss the gap.
+# DRY_RUN=1 Print new IDs but skip the Slack post AND skip
+# the state-file update. Useful for testing.
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MESSAGES_ROUTE_FILE="${MESSAGES_ROUTE_FILE:-$SCRIPT_DIR/../src/proxy/router/messages_route.js}"
+AWS_BEDROCK_URL="${AWS_BEDROCK_URL:-https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html}"
+STATE_DIR="${STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/evolver}"
+STATE_FILE="$STATE_DIR/bedrock-alias-watch.json"
+LOCK_DIR="$STATE_DIR/bedrock-alias-watch.lock"
+
+# Regional prefix alternation. The grep/sed regexes below use
+# PREFIX_REGEX (the | separated alternation wrapped in (...) anchoring)
+# rather than a hardcoded literal list. AWS-side additions (e.g. a future
+# jp.* or me.*) can be picked up by exporting BEDROCK_REGIONAL_PREFIXES.
+# The (...) wrapping matters: a bare `${BEDROCK_REGIONAL_PREFIXES}` would
+# expand to `global|us|eu|ap` and parse as `global OR us OR eu OR ap` in
+# sed (unanchored) instead of the intended `(global|us|eu|ap)`.
+BEDROCK_REGIONAL_PREFIXES="${BEDROCK_REGIONAL_PREFIXES:-global|us|eu|ap}"
+PREFIX_REGEX="(${BEDROCK_REGIONAL_PREFIXES})"
+
+log() { printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }
+die() { log "ERROR: $*"; exit 1; }
+
+for cmd in curl jq grep sort comm mktemp; do
+ command -v "$cmd" >/dev/null 2>&1 || die "missing required command: $cmd"
+done
+
+# Lock + trap BEFORE doing any work. The trap is installed first so a crash
+# between any later command and the end of the script can't leak the lock.
+# `rmdir "$LOCK_DIR" 2>/dev/null || true` is safe even when mkdir failed
+# (the dir doesn't exist) or is foreign-owned (rmdir of a non-empty or
+# foreign dir fails silently under `|| true`).
+TMP_FILES=()
+cleanup() { rm -f "${TMP_FILES[@]}" 2>/dev/null || true; rmdir "$LOCK_DIR" 2>/dev/null || true; }
+trap cleanup EXIT
+
+if ! mkdir "$LOCK_DIR" 2>/dev/null; then
+ log "another run is in progress; exiting"
+ exit 0
+fi
+
+# --- 1. Parse KNOWN_BEDROCK_ALIASES from the JS source.
+[[ -f "$MESSAGES_ROUTE_FILE" ]] || die "messages_route.js not found at $MESSAGES_ROUTE_FILE"
+KNOWN_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$KNOWN_KEYS_FILE")
+grep -oE "'(opus|sonnet|haiku)/[0-9]+/[0-9]+'" "$MESSAGES_ROUTE_FILE" \
+ | tr -d "'" | sort -u > "$KNOWN_KEYS_FILE"
+
+KNOWN_FULL_FILE="$(mktemp)"; TMP_FILES+=("$KNOWN_FULL_FILE")
+grep -oE "'${PREFIX_REGEX}\.anthropic\.claude-[a-z0-9.:-]+'" "$MESSAGES_ROUTE_FILE" \
+ | tr -d "'" | sort -u > "$KNOWN_FULL_FILE"
+
+# canon -> full_id map. Invariant: KNOWN_BEDROCK_ALIASES has exactly one
+# entry per canon (family/major/minor), so each canon appears at most
+# once below. The dated-revision loop relies on this to pick the right
+# full ID for the prefix comparison.
+KNOWN_MAP_FILE="$(mktemp)"; TMP_FILES+=("$KNOWN_MAP_FILE")
+while IFS= read -r full_id; do
+ canon="$(printf '%s' "$full_id" | sed -E "s/^${PREFIX_REGEX}\.anthropic\.claude-([a-z]+)-([0-9]+)-([0-9]+).*/\2\/\3\/\4/")"
+ printf '%s|%s\n' "$canon" "$full_id"
+done < "$KNOWN_FULL_FILE" > "$KNOWN_MAP_FILE"
+log "known family/major/minor: $(wc -l < "$KNOWN_KEYS_FILE" | tr -d ' ') full IDs: $(wc -l < "$KNOWN_FULL_FILE" | tr -d ' ')"
+
+# --- 1b. Smoke check on KNOWN_BEDROCK_ALIASES: warn if any entry has a
+# regional prefix that is not in the configured BEDROCK_REGIONAL_PREFIXES.
+# If AWS adds `jp.*` (or similar) and an operator forgets to extend either
+# the env var or the table, this check catches it before the diff pass.
+# Backed by UNKNOWN_PREFIX_FILE + a single log() loop -- cheap even on
+# thousands of entries.
+UNKNOWN_PREFIX_FILE="$(mktemp)"; TMP_FILES+=("$UNKNOWN_PREFIX_FILE")
+while IFS= read -r full_id; do
+ # `tr -d "'"` strips the JS single-quote artifacts the grep above emitted
+ # so the prefix match works on a clean "global." / "us." token.
+ normalised="$(printf '%s' "$full_id" | tr -d "'")"
+ prefix="$(printf '%s' "$normalised" | cut -d. -f1)"
+ # Match $prefix against the configured | separated literal list.
+ # `printf | tr | grep -Fxq` is the bash-no-array idiom for "in list".
+ if ! printf '%s\n' "$BEDROCK_REGIONAL_PREFIXES" | tr '|' '\n' | grep -Fxq "$prefix"; then
+ printf '%s\n' "$normalised"
+ fi
+done < "$KNOWN_FULL_FILE" > "$UNKNOWN_PREFIX_FILE" || true
+UNKNOWN_COUNT="$(wc -l < "$UNKNOWN_PREFIX_FILE" | tr -d ' ')"
+if [[ "$UNKNOWN_COUNT" -gt 0 ]]; then
+ log "WARN: $UNKNOWN_COUNT known alias(es) have a regional prefix not in BEDROCK_REGIONAL_PREFIXES (default: 'global|us|eu|ap'). Operator should review + extend BEDROCK_REGIONAL_PREFIXES if AWS added a new region:"
+ while IFS= read -r unknown_id; do
+ log " - $unknown_id"
+ done < "$UNKNOWN_PREFIX_FILE"
+fi
+
+# --- 1c. Smoke check direction B: warn if BEDROCK_REGIONAL_PREFIXES has a
+# non-default prefix that has NO matching KNOWN_BEDROCK_ALIASES entry.
+# Catches operator typos (e.g. 'europe' instead of 'eu') without
+# nagging about legitimate extensions (e.g. 'jp') once the table is
+# updated to match. Inverse semantics from direction A (step 1b,
+# flags KNOWN prefixes not in env var): direction B flags env tokens
+# that KNOWN has not caught up with.
+DEFAULT_PREFIXES='global|us|eu|ap'
+KNOWN_PREFIXES_FILE="$(mktemp)"; TMP_FILES+=("$KNOWN_PREFIXES_FILE")
+cut -d. -f1 "$KNOWN_FULL_FILE" | sort -u > "$KNOWN_PREFIXES_FILE" || true
+EXTRA_TOKEN_FILE="$(mktemp)"; TMP_FILES+=("$EXTRA_TOKEN_FILE")
+# Process substitution `-- < <(...)` keeps the while loop in the parent shell
+# so `continue` works correctly across the pipe.
+while IFS= read -r token; do
+ printf '%s\n' "$DEFAULT_PREFIXES" | tr '|' '\n' | grep -Fxq "$token" && continue
+ grep -Fxq "$token" "$KNOWN_PREFIXES_FILE" || printf '%s\n' "$token"
+done < <(printf '%s\n' "$BEDROCK_REGIONAL_PREFIXES" | tr '|' '\n' | sort -u) \
+ > "$EXTRA_TOKEN_FILE" || true
+EXTRA_COUNT="$(wc -l < "$EXTRA_TOKEN_FILE" | tr -d ' ')"
+if [[ "$EXTRA_COUNT" -gt 0 ]]; then
+ log "WARN: $EXTRA_COUNT env-var regional prefix(es) are non-default AND have no KNOWN_BEDROCK_ALIASES entry. Likely operator typo (e.g. 'europe' instead of 'eu') -- fix BEDROCK_REGIONAL_PREFIXES or add a matching entry to the table. The prefix will re-fire this WARN until addressed either way:"
+ while IFS= read -r extra_token; do
+ log " - $extra_token"
+ done < "$EXTRA_TOKEN_FILE"
+fi
+
+# --- 2. Load previously-seen keys + dated IDs from the state file.
+# Backwards-compat: read either `seen_keys` (current) or `seen_ids`
+# (round-1 format) so existing state files aren't invalidated.
+mkdir -p "$STATE_DIR"
+SEEN_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$SEEN_KEYS_FILE")
+SEEN_DATED_FILE="$(mktemp)"; TMP_FILES+=("$SEEN_DATED_FILE")
+SEEN_RETIRED_FILE="$(mktemp)"; TMP_FILES+=("$SEEN_RETIRED_FILE")
+if [[ -f "$STATE_FILE" ]]; then
+ jq -r '(.seen_keys // .seen_ids // empty)[]?' "$STATE_FILE" 2>/dev/null | sort -u > "$SEEN_KEYS_FILE" || true
+ jq -r '(.seen_dated_ids // empty)[]?' "$STATE_FILE" 2>/dev/null | sort -u > "$SEEN_DATED_FILE" || true
+ jq -r '(.seen_retired // empty)[]?' "$STATE_FILE" 2>/dev/null | sort -u > "$SEEN_RETIRED_FILE" || true
+fi
+log "previously seen: $(wc -l < "$SEEN_KEYS_FILE" | tr -d ' ') keys, $(wc -l < "$SEEN_DATED_FILE" | tr -d ' ') dated, $(wc -l < "$SEEN_RETIRED_FILE" | tr -d ' ') retired"
+
+# --- 3. Fetch the AWS doc + extract both keys and full IDs.
+HTML_FILE="$(mktemp)"; TMP_FILES+=("$HTML_FILE")
+if ! curl -fsSL --max-time 30 "$AWS_BEDROCK_URL" -o "$HTML_FILE"; then
+ log "WARN: AWS fetch failed; skipping (state NOT updated, will retry tomorrow)"
+ exit 0
+fi
+AWS_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$AWS_KEYS_FILE")
+AWS_FULL_FILE="$(mktemp)"; TMP_FILES+=("$AWS_FULL_FILE")
+grep -oE "${PREFIX_REGEX}\.anthropic\.claude-(opus|sonnet|haiku)-[0-9]+-[0-9]+" "$HTML_FILE" \
+ | sed -E 's/.*claude-([a-z]+)-([0-9]+)-([0-9]+).*/\1\/\2\/\3/' | sort -u > "$AWS_KEYS_FILE"
+grep -oE "${PREFIX_REGEX}\.anthropic\.claude-[a-z0-9.:-]+" "$HTML_FILE" | sort -u > "$AWS_FULL_FILE"
+log "AWS-listed: $(wc -l < "$AWS_KEYS_FILE" | tr -d ' ') keys, $(wc -l < "$AWS_FULL_FILE" | tr -d ' ') full IDs"
+
+# --- 4a. New family/major/minor: (AWS \ KNOWN) \ SEEN.
+NEW_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$NEW_KEYS_FILE")
+comm -23 "$AWS_KEYS_FILE" "$KNOWN_KEYS_FILE" | comm -23 - "$SEEN_KEYS_FILE" > "$NEW_KEYS_FILE" || true
+NEW_KEYS_COUNT="$(wc -l < "$NEW_KEYS_FILE" | tr -d ' ')"
+
+# --- 4b. Dated revision: a same-region full ID whose family/major/minor
+# is already in the table but whose full ID is new.
+# Cross-region siblings (e.g. us.* when table has global.*) are
+# intentionally skipped — the canonicalizer handles them.
+DATED_FILE="$(mktemp)"; TMP_FILES+=("$DATED_FILE")
+while IFS= read -r aws_id; do
+ # Skip if the full ID is already known
+ grep -qx "$aws_id" "$KNOWN_FULL_FILE" && continue
+ # Skip if the family/major/minor isn't in the table (handled by 4a)
+ canon="$(printf '%s' "$aws_id" | sed -E "s/^${PREFIX_REGEX}\.anthropic\.claude-([a-z]+)-([0-9]+)-([0-9]+).*/\2\/\3\/\4/")"
+ grep -qx "$canon" "$KNOWN_KEYS_FILE" || continue
+ # Find the table's full ID for this family
+ known_full="$(grep -E "^${canon}[|]" "$KNOWN_MAP_FILE" | head -1 | cut -d'|' -f2-)"
+ [[ -z "$known_full" ]] && continue
+ # Same regional prefix? → dated revision. Different? → cross-region
+ # sibling — INTENTIONALLY SKIPPED: the canonicalizer rewrites the
+ # inbound to the table's value regardless of the dated suffix, so
+ # there's no table update to do. Alerting here would be a false positive.
+ [[ "${aws_id%%.*}" != "${known_full%%.*}" ]] && continue
+ # Skip if we've already alerted on this dated ID
+ grep -qx "$aws_id" "$SEEN_DATED_FILE" && continue
+ printf '%s|%s\n' "$canon" "$aws_id"
+done < "$AWS_FULL_FILE" > "$DATED_FILE" || true
+DATED_COUNT="$(wc -l < "$DATED_FILE" | tr -d ' ')"
+
+# --- 4c. Retired: a family/major/minor in KNOWN_BEDROCK_ALIASES but no
+# longer listed on AWS. The canonicalizer would still try to
+# rewrite inbounds to the table's (now-Bedrock-rejected) value,
+# so the operator needs to know to remove the entry.
+RETIRED_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$RETIRED_KEYS_FILE")
+comm -23 "$KNOWN_KEYS_FILE" "$AWS_KEYS_FILE" | comm -23 - "$SEEN_RETIRED_FILE" > "$RETIRED_KEYS_FILE" || true
+RETIRED_COUNT="$(wc -l < "$RETIRED_KEYS_FILE" | tr -d ' ')"
+
+log "diff: $NEW_KEYS_COUNT new key(s), $DATED_COUNT dated revision(s), $RETIRED_COUNT retired"
+
+# --- 5. Notify if either diff has entries.
+if [[ "$NEW_KEYS_COUNT" -gt 0 || "$DATED_COUNT" -gt 0 || "$RETIRED_COUNT" -gt 0 ]]; then
+ MSG_FILE="$(mktemp)"; TMP_FILES+=("$MSG_FILE")
+ {
+ [[ "$NEW_KEYS_COUNT" -gt 0 ]] && {
+ printf 'Anthropic Bedrock published %d new family/major/minor not yet in `KNOWN_BEDROCK_ALIASES`:\n' "$NEW_KEYS_COUNT"
+ while IFS= read -r key; do printf ' • `%s`\n' "$key"; done < "$NEW_KEYS_FILE"
+ }
+ [[ "$DATED_COUNT" -gt 0 ]] && {
+ printf '%d dated revision(s) of an existing family/major/minor — update the VALUE in the table:\n' "$DATED_COUNT"
+ while IFS='|' read -r canon aws_id; do
+ # Look up the table's current value for this family so the operator
+ # can see at a glance what changed. Suffix is the part after
+ # claude-{family}-{major}-{minor} — empty for bare IDs, "-YYYYMMDD-v1:0"
+ # for dated forms. "" is shown for empty suffixes so the
+ # was/now pair always has two visible values.
+ old_full="$(grep -E "^${canon}[|]" "$KNOWN_MAP_FILE" | head -1 | cut -d'|' -f2-)"
+ old_suffix="$(printf '%s' "$old_full" | sed -E 's/.*claude-[a-z]+-[0-9]+-[0-9]+//')"
+ new_suffix="$(printf '%s' "$aws_id" | sed -E 's/.*claude-[a-z]+-[0-9]+-[0-9]+//')"
+ printf ' • `%s` — was: `%s`, now: `%s`\n' \
+ "$canon" "${old_suffix:-}" "${new_suffix:-}"
+ done < "$DATED_FILE"
+ }
+ [[ "$RETIRED_COUNT" -gt 0 ]] && {
+ printf '%d family/major/minor no longer listed on AWS Bedrock (possibly retired — the canonicalizer would 400 inbounds to these):\n' "$RETIRED_COUNT"
+ while IFS= read -r key; do
+ # Look up the full ID for operator context. [\|] is a character class
+ # containing the literal | (ERE alternation `|` would treat it as OR).
+ full_id="$(grep -E "^${key}[|]" "$KNOWN_MAP_FILE" | head -1 | cut -d'|' -f2-)"
+ printf ' • `%s` (was: `%s`)\n' "$key" "$full_id"
+ done < "$RETIRED_KEYS_FILE"
+ }
+ # Per-section instructions are inline; the followup line is the
+ # same regardless of which categories fired.
+ printf 'See the per-section instructions above for the action to take on evolver/src/proxy/router/messages_route.js.\n'
+ } > "$MSG_FILE"
+
+ if [[ "${DRY_RUN:-0}" == "1" ]]; then
+ log "DRY_RUN=1: would post the following to Slack:"
+ cat "$MSG_FILE" >&2
+ elif [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then
+ PAYLOAD_FILE="$(mktemp)"; TMP_FILES+=("$PAYLOAD_FILE")
+ jq -Rs '{text: .}' < "$MSG_FILE" > "$PAYLOAD_FILE"
+ if curl -fsS --max-time 15 -X POST -H 'Content-Type: application/json' \
+ --data @"$PAYLOAD_FILE" "$SLACK_WEBHOOK_URL" >/dev/null; then
+ log "posted $((NEW_KEYS_COUNT + DATED_COUNT)) new entry/entries to Slack"
+ else
+ log "WARN: Slack post failed; entries:"
+ cat "$MSG_FILE" >&2
+ fi
+ else
+ log "SLACK_WEBHOOK_URL unset; entries (operator should configure webhook and update KNOWN_BEDROCK_ALIASES):"
+ cat "$MSG_FILE" >&2
+ fi
+fi
+
+# --- 6. Persist state — union of SEEN + AWS. Uses mktemp INSIDE STATE_DIR
+# so the final `mv` is a same-FS rename (atomic on POSIX) regardless
+# of whether /tmp is tmpfs.
+if [[ "${DRY_RUN:-0}" == "1" ]]; then
+ log "DRY_RUN=1: skipping state update"
+ exit 0
+fi
+ALL_KEYS_FILE="$(mktemp)"; TMP_FILES+=("$ALL_KEYS_FILE")
+cat "$SEEN_KEYS_FILE" "$AWS_KEYS_FILE" | sort -u > "$ALL_KEYS_FILE"
+# seen_dated_ids ∪ (just the aws_id column from DATED_FILE)
+NEW_DATED_IDS_FILE="$(mktemp)"; TMP_FILES+=("$NEW_DATED_IDS_FILE")
+awk -F'|' '$2 != "" {print $2}' "$DATED_FILE" | sort -u > "$NEW_DATED_IDS_FILE"
+ALL_DATED_FILE="$(mktemp)"; TMP_FILES+=("$ALL_DATED_FILE")
+cat "$SEEN_DATED_FILE" "$NEW_DATED_IDS_FILE" | sort -u > "$ALL_DATED_FILE"
+# seen_retired: union of (still-retired entries) ∪ (newly-retired keys).
+# "still-retired" = (previous) ∩ KNOWN ∩ (not in AWS)
+# — drops entries that came back to AWS
+# — drops entries the operator removed from the table
+# "newly-retired" = RETIRED_KEYS (just alerted above)
+SEEN_RETIRED_NEXT_FILE="$(mktemp)"; TMP_FILES+=("$SEEN_RETIRED_NEXT_FILE")
+comm -12 "$SEEN_RETIRED_FILE" "$KNOWN_KEYS_FILE" | comm -23 - "$AWS_KEYS_FILE" > "$SEEN_RETIRED_NEXT_FILE" || true
+ALL_RETIRED_FILE="$(mktemp)"; TMP_FILES+=("$ALL_RETIRED_FILE")
+cat "$SEEN_RETIRED_NEXT_FILE" "$RETIRED_KEYS_FILE" | sort -u > "$ALL_RETIRED_FILE"
+
+TMP_STATE="$(mktemp "$STATE_DIR/.state.XXXXXX")"; TMP_FILES+=("$TMP_STATE")
+jq -n --arg last_run "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ --rawfile keys "$ALL_KEYS_FILE" \
+ --rawfile dated "$ALL_DATED_FILE" \
+ --rawfile retired "$ALL_RETIRED_FILE" \
+ '{last_run: $last_run,
+ seen_keys: ($keys | split("\n") | map(select(length > 0))),
+ seen_dated_ids: ($dated | split("\n") | map(select(length > 0))),
+ seen_retired: ($retired | split("\n") | map(select(length > 0)))}' \
+ > "$TMP_STATE"
+mv "$TMP_STATE" "$STATE_FILE"
+log "state updated: $(jq '.seen_keys | length' "$STATE_FILE") keys, $(jq '.seen_dated_ids | length' "$STATE_FILE") dated, $(jq '.seen_retired | length' "$STATE_FILE") retired"
diff --git a/scripts/check-readme-links.js b/scripts/check-readme-links.js
new file mode 100644
index 00000000..9c642d51
--- /dev/null
+++ b/scripts/check-readme-links.js
@@ -0,0 +1,247 @@
+#!/usr/bin/env node
+//
+// check-readme-links.js — verify that every internal cross-reference
+// link in the repo's README files points at an existing file and (when
+// given) an existing heading in that file. Catches deep-link drift
+// after future edits — e.g. renaming a section without updating the
+// `[link](#anchor)` references that pointed at it.
+//
+// Scope (configurable via --include / --exclude below):
+// - README.md
+// - README.zh-CN.md
+// - README.ja-JP.md
+// - README.ko-KR.md
+// - SKILL.md (Proxy mailbox API; referenced from each README)
+// - dev-fixtures/README.md
+//
+// Rules implemented:
+// 1. Code fences (``` fenced blocks) delimit "code" so links/headings
+// inside them aren't picked up as actual references.
+// 2. The anchor slugify function approximates GitHub's auto-anchor
+// rule: lowercase, spaces → dashes, drop chars that aren't
+// Unicode letters/numbers/underscore/dash, collapse consecutive
+// dashes. Unicode-property escapes (\p{L}, \p{N}) keep CJK,
+// Hangul, Hiragana, Katakana, and Latin-accented chars intact so
+// heading text from the localized README siblings round-trips
+// through slugify unchanged. GitHub additionally strips emojis
+// and normalizes unicode accents; this approximation covers the
+// current heading corpus and adds the ko-KR scope without
+// regressing the existing zh-CN / ja-JP refs.
+//
+// Exit codes:
+// 0 every link resolves
+// 1 one or more links are broken (missing file, missing anchor,
+// dangling self-anchor)
+//
+// Usage: node scripts/check-readme-links.js
+//
+
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+const REPO_ROOT = path.resolve(__dirname, '..');
+
+// Default include set — all top-level READMEs + the dev-fixtures one.
+// Override via CLI: `node check-readme-links.js --include=README.md,README.zh-CN.md`.
+const DEFAULT_INCLUDES = [
+ 'README.md',
+ 'README.zh-CN.md',
+ 'README.ja-JP.md',
+ 'README.ko-KR.md',
+ 'SKILL.md',
+ 'dev-fixtures/README.md',
+];
+
+function parseArgs(argv) {
+ const opts = { include: null };
+ for (const a of argv.slice(2)) {
+ if (a.startsWith('--include=')) {
+ opts.include = a.slice('--include='.length).split(',').filter(Boolean);
+ } else if (a === '--help' || a === '-h') {
+ opts.help = true;
+ }
+ }
+ return opts;
+}
+
+function printHelp() {
+ console.log('Usage: node scripts/check-readme-links.js [--include=FILE,FILE,...]');
+ console.log('');
+ console.log('Default include set:');
+ for (const f of DEFAULT_INCLUDES) console.log(' ' + f);
+ console.log('');
+ console.log('Exit codes:');
+ console.log(' 0 every link resolves');
+ console.log(' 1 one or more links are broken');
+}
+
+// --- slugify (GitHub auto-anchor approximation) -----------------------
+
+function slugify(headingText) {
+ return headingText
+ // GitHub renders anchors by NFD-decomposing + stripping combining
+ // marks BEFORE applying the rest of the slugify rules, so accented
+ // Latin (caf\u00e9, na\u00efve) collapses onto the unaccented anchor
+ // (cafe, naive). Mirror that here so script-derived lookups match
+ // GitHub-rendered anchors for any future heading that uses accented
+ // Latin. (No current heading does, so this is forward-looking.)
+ // \p{M} covers every Unicode combining-mark category, BMP (U+0300-
+ // U+036F) and supplementary (U+1AB0-U+1AFF, U+1DC0-U+1DFF,
+ // U+20D0-U+20FF, U+FE20-U+FE2F), in one Unicode-property symbol
+ // that mirrors what github-slugger effectively strips.
+ .normalize('NFD')
+ .replace(/\p{M}/gu, '')
+ .toLowerCase()
+ // collapse runs of whitespace into one dash
+ .replace(/\s+/g, '-')
+ // drop everything outside Unicode letters/numbers/underscore/dash.
+ // The Unicode property escapes (\p{L}, \p{N}) keep CJK, Hangul,
+ // Hiragana, Katakana, and Latin-accented chars intact so localized
+ // heading text round-trips through slugify unchanged. Emojis and
+ // punctuation that GitHub also drops are likewise filtered out.
+ .replace(/[^\p{L}\p{N}_-]/gu, '')
+ // collapse consecutive dashes
+ .replace(/-+/g, '-')
+ // trim leading/trailing dashes
+ .replace(/^-+|-+$/g, '');
+}
+
+// --- markdown parser (code-fence aware) ------------------------------
+
+/**
+ * Iterates the lines of a markdown file, tagging each line as 'text' or
+ * 'code' depending on whether we're inside a ``` fenced block. Lets us
+ * skip links and headings that appear inside code samples.
+ */
+function* iterLines(content) {
+ let inFence = false;
+ let fenceMarker = null;
+ for (const line of content.split('\n')) {
+ // Match an opening/closing fence: 3+ backticks (optionally followed
+ // by an info string like ```bash). Match at column 0 only — an
+ // indented ``` is just literal text.
+ const m = line.match(/^(`{3,})/);
+ if (m && (!inFence || m[1].length >= fenceMarker)) {
+ inFence = !inFence;
+ if (inFence) fenceMarker = m[1].length;
+ yield { type: 'fence', line };
+ continue;
+ }
+ yield { type: inFence ? 'code' : 'text', line };
+ }
+}
+
+function extractLinks(filePath) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const links = [];
+ // Match [text](url). Text may contain newlines in real Markdown, but
+ // for our READMEs the links are single-line, so this is fine.
+ const re = /\[([^\]\n]+)\]\(([^)\n]+)\)/g;
+ for (const { type, line } of iterLines(content)) {
+ if (type === 'code') continue;
+ let m;
+ while ((m = re.exec(line)) !== null) {
+ links.push({ text: m[1], url: m[2], line: line.trim() });
+ }
+ }
+ return links;
+}
+
+function extractHeadings(filePath) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const headings = [];
+ const re = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
+ for (const { type, line } of iterLines(content)) {
+ if (type === 'code') continue;
+ const m = line.match(re);
+ if (m) headings.push({ level: m[1].length, text: m[2], slug: slugify(m[2]) });
+ }
+ return headings;
+}
+
+// --- core check ------------------------------------------------------
+
+function main() {
+ const opts = parseArgs(process.argv);
+ if (opts.help) { printHelp(); return 0; }
+
+ const includes = opts.include && opts.include.length > 0 ? opts.include : DEFAULT_INCLUDES;
+
+ // Build { relPath -> Set(slug) } for every included file's headings.
+ const headingSlugs = new Map();
+ for (const rel of includes) {
+ const abs = path.join(REPO_ROOT, rel);
+ headingSlugs.set(rel, new Set(extractHeadings(abs).map(h => h.slug)));
+ }
+
+ // Walk every link in every included file. A link is interesting only
+ // if it points at another *.md file (possibly with a #anchor).
+ const failures = [];
+ let totalLinks = 0;
+
+ for (const fromRel of includes) {
+ const fromAbs = path.join(REPO_ROOT, fromRel);
+ const links = extractLinks(fromAbs);
+ for (const link of links) {
+ // We're only auditing links that target a *.md file. External
+ // http(s) URLs and `mailto:` links aren't checked here.
+ if (!/\.md(?:$|#|\?)/.test(link.url)) continue;
+ totalLinks++;
+
+ // Split path#anchor (anchor is optional).
+ const hashIdx = link.url.indexOf('#');
+ const pathPart = hashIdx === -1 ? link.url : link.url.slice(0, hashIdx);
+ const anchorPart = hashIdx === -1 ? null : link.url.slice(hashIdx + 1);
+
+ // Resolve pathPart relative to the file the link appears in.
+ // Empty pathPart means this is a self-only anchor ([text](#foo)
+ // in the same file).
+ let targetRel;
+ if (pathPart === '') {
+ targetRel = fromRel;
+ } else {
+ const targetAbs = path.resolve(path.dirname(fromAbs), pathPart);
+ targetRel = path.relative(REPO_ROOT, targetAbs);
+ }
+
+ if (!headingSlugs.has(targetRel)) {
+ failures.push({
+ kind: 'missing-file',
+ fromFile: fromRel,
+ linkText: link.text,
+ linkUrl: link.url,
+ msg: `target file '${targetRel}' is not in scope (or doesn't exist)`,
+ });
+ continue;
+ }
+
+ if (anchorPart !== null && !headingSlugs.get(targetRel).has(anchorPart)) {
+ // Find an approximate match to give the operator a hint.
+ const known = [...headingSlugs.get(targetRel)];
+ const close = known.filter(s => s.includes(anchorPart) || anchorPart.includes(s)).slice(0, 5);
+ failures.push({
+ kind: 'broken-anchor',
+ fromFile: fromRel,
+ linkText: link.text,
+ linkUrl: link.url,
+ msg: `anchor '#${anchorPart}' not found in '${targetRel}'` + (close.length ? ` (close: ${close.join(', ')})` : ''),
+ });
+ }
+ }
+ }
+
+ if (failures.length > 0) {
+ console.error(`FAIL: ${failures.length} broken/missing referent(s) across ${totalLinks} cross-link(s):`);
+ for (const f of failures) {
+ console.error(` ${f.fromFile}: [${f.linkText}](${f.linkUrl})`);
+ console.error(` -> ${f.msg}`);
+ }
+ return 1;
+ }
+ console.log(`PASS: ${totalLinks} cross-link(s) across ${includes.length} README file(s) resolve correctly.`);
+ return 0;
+}
+
+process.exit(main());
diff --git a/scripts/dev-slack-receiver.js b/scripts/dev-slack-receiver.js
new file mode 100755
index 00000000..2844649c
--- /dev/null
+++ b/scripts/dev-slack-receiver.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+// Tiny local Slack receiver for `make watch`.
+//
+// Listens on 127.0.0.1 with a random port (so it can't conflict with
+// anything), writes the chosen port to --port-file, and appends each
+// POST body to --log-file (pretty-printed if it's JSON). The parent
+// watch script (scripts/dev-watch.sh) tails the log file so the
+// operator sees the Slack payload in real time as they edit the
+// fixture.
+//
+// Usage:
+// node scripts/dev-slack-receiver.js \
+// --port-file=dev-fixtures/.receiver.port \
+// --log-file=dev-fixtures/receiver.log \
+// --log-prefix=[slack-receiver]
+
+'use strict';
+
+const http = require('node:http');
+const fs = require('node:fs');
+
+function arg(name) {
+ const prefix = `--${name}=`;
+ for (const a of process.argv.slice(2)) {
+ if (a.startsWith(prefix)) return a.slice(prefix.length);
+ }
+ return undefined;
+}
+
+const portFile = arg('port-file');
+const logFile = arg('log-file');
+const logPrefix = arg('log-prefix') || '[slack-receiver]';
+
+if (!portFile || !logFile) {
+ console.error('Usage: dev-slack-receiver.js --port-file=... --log-file=... [--log-prefix=...]');
+ process.exit(2);
+}
+
+const logStream = fs.createWriteStream(logFile, { flags: 'a' });
+const writeLog = (msg) => {
+ const line = `${logPrefix} ${msg}\n`;
+ logStream.write(line);
+};
+
+const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ // Pretty-print JSON payloads for readability; fall back to raw.
+ let display = raw;
+ if (raw.length > 0) {
+ try { display = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* not JSON */ }
+ }
+ writeLog(`POST ${req.url} (${raw.length} bytes)`);
+ for (const line of display.split('\n')) writeLog(` ${line}`);
+ writeLog('---');
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('ok');
+ });
+ req.on('error', () => { /* client-side error: ignore */ });
+});
+
+server.on('error', (err) => {
+ writeLog(`server error: ${err.message}`);
+ process.exit(1);
+});
+
+server.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ fs.writeFileSync(portFile, String(port));
+ writeLog(`listening on http://127.0.0.1:${port}`);
+});
+
+// Clean shutdown — the parent script sends SIGTERM on Ctrl-C.
+for (const sig of ['SIGINT', 'SIGTERM']) {
+ process.on(sig, () => {
+ writeLog(`shutting down (${sig})`);
+ server.close(() => process.exit(0));
+ // Hard exit if the server doesn't close cleanly.
+ setTimeout(() => process.exit(0), 200).unref();
+ });
+}
diff --git a/scripts/dev-watch.sh b/scripts/dev-watch.sh
new file mode 100755
index 00000000..5d2833ff
--- /dev/null
+++ b/scripts/dev-watch.sh
@@ -0,0 +1,127 @@
+#!/usr/bin/env bash
+# Dev watch loop for evolver/scripts/bedrock-alias-watch.sh.
+#
+# Starts a local Slack receiver in the background (so the watch script
+# can POST somewhere real) and re-runs the watch script every
+# WATCH_INTERVAL seconds (default 60). The operator edits
+# dev-fixtures/aws.html in real time and sees the resulting Slack
+# payload printed in their terminal.
+#
+# Usage:
+# bash scripts/dev-watch.sh # 60s interval
+# WATCH_INTERVAL=10 bash scripts/dev-watch.sh # 10s interval
+# make watch-fresh # clear state, then watch
+#
+# On Ctrl-C the receiver is killed and the loop exits cleanly.
+#
+# Layout (all under dev-fixtures/):
+# aws.html — mock AWS doc (operator edits this)
+# messages_route.js — mock KNOWN_BEDROCK_ALIASES table
+# state/ — watch state (gitignored)
+# .receiver.port — receiver port (gitignored)
+# .receiver.pid — receiver pid (gitignored)
+# receiver.log — receiver log (gitignored, tailed in this terminal)
+
+set -euo pipefail
+
+WATCH_INTERVAL="${WATCH_INTERVAL:-60}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+DEV_DIR="$ROOT_DIR/dev-fixtures"
+WATCH_SCRIPT="$SCRIPT_DIR/bedrock-alias-watch.sh"
+RECEIVER_SCRIPT="$SCRIPT_DIR/dev-slack-receiver.js"
+PORT_FILE="$DEV_DIR/.receiver.port"
+PID_FILE="$DEV_DIR/.receiver.pid"
+LOG_FILE="$DEV_DIR/receiver.log"
+
+mkdir -p "$DEV_DIR/state"
+
+# Kill any stale receiver from a previous run that didn't shut down
+# cleanly. We match by command name (not by pid file) to avoid the
+# PID-reuse risk — if the OS recycled the old pid for an unrelated
+# process, a pid-based kill would terminate the wrong target. The
+# receiver's listen(0) means the new instance gets a fresh port
+# regardless, so stale processes are harmless aside from leaked memory.
+pkill -f 'node.*dev-slack-receiver\.js' 2>/dev/null || true
+# Give the OS a moment to release the pkill target.
+sleep 0.1
+rm -f "$PID_FILE" "$PORT_FILE"
+
+RECEIVER_PID=""
+TAIL_PID=""
+
+cleanup() {
+ echo ""
+ echo "[dev-watch] shutting down..."
+ if [[ -n "$TAIL_PID" ]] && kill -0 "$TAIL_PID" 2>/dev/null; then
+ kill "$TAIL_PID" 2>/dev/null || true
+ fi
+ if [[ -n "$RECEIVER_PID" ]] && kill -0 "$RECEIVER_PID" 2>/dev/null; then
+ kill "$RECEIVER_PID" 2>/dev/null || true
+ sleep 0.2
+ kill -9 "$RECEIVER_PID" 2>/dev/null || true
+ fi
+ rm -f "$PID_FILE" "$PORT_FILE"
+ echo "[dev-watch] done"
+}
+trap cleanup EXIT INT TERM
+
+# Start the Slack receiver in the background.
+echo "[dev-watch] starting local Slack receiver..."
+node "$RECEIVER_SCRIPT" \
+ --port-file="$PORT_FILE" \
+ --log-file="$LOG_FILE" \
+ --log-prefix="[slack-receiver]" \
+ >/dev/null 2>&1 &
+RECEIVER_PID=$!
+echo "$RECEIVER_PID" > "$PID_FILE"
+
+# Wait for the receiver to write its port (max 2s).
+for _ in {1..40}; do
+ if [[ -s "$PORT_FILE" ]]; then break; fi
+ sleep 0.05
+done
+if [[ ! -s "$PORT_FILE" ]]; then
+ echo "[dev-watch] receiver failed to start — see $LOG_FILE"
+ exit 1
+fi
+
+PORT="$(cat "$PORT_FILE")"
+
+# Truncate the log so this run starts with a clean slate.
+: > "$LOG_FILE"
+
+# Tail the receiver log so the operator sees the payload in real time.
+# This is separate from the watch script's own stderr output, so the
+# terminal shows both: the script's "diff: …" log lines + the Slack
+# payload that the script posted.
+tail -n 0 -f "$LOG_FILE" &
+TAIL_PID=$!
+
+echo "[dev-watch] receiver listening on http://127.0.0.1:$PORT"
+echo "[dev-watch] edit $DEV_DIR/aws.html to add/remove model IDs"
+echo "[dev-watch] watch interval: ${WATCH_INTERVAL}s (override: WATCH_INTERVAL=10 bash scripts/dev-watch.sh)"
+echo "[dev-watch] Ctrl-C to stop"
+echo ""
+
+i=0
+while true; do
+ i=$((i+1))
+ echo "=== run $i at $(date -Iseconds) ==="
+ # Run the watch script. A non-zero exit (e.g. AWS fetch fail) is
+ # expected in some scenarios and shouldn't kill the watch loop.
+ # DRY_RUN=0 is set explicitly so a stale DRY_RUN=1 in the operator's
+ # shell env doesn't silently suppress the Slack post.
+ STATE_DIR="$DEV_DIR/state" \
+ MESSAGES_ROUTE_FILE="$DEV_DIR/messages_route.js" \
+ AWS_BEDROCK_URL="file://$DEV_DIR/aws.html" \
+ SLACK_WEBHOOK_URL="http://127.0.0.1:$PORT/slack" \
+ DRY_RUN=0 \
+ bash "$WATCH_SCRIPT" || echo "[dev-watch] watch script exited non-zero (continuing)"
+ if [[ "$WATCH_INTERVAL" == "0" ]]; then
+ # Single-run mode (used by `make watch-once`).
+ break
+ fi
+ echo "[dev-watch] sleeping ${WATCH_INTERVAL}s... (Ctrl-C to stop)"
+ sleep "$WATCH_INTERVAL"
+done
diff --git a/src/atp/hubClient.js b/src/atp/hubClient.js
index 1f18893a..c0de500b 100644
--- a/src/atp/hubClient.js
+++ b/src/atp/hubClient.js
@@ -160,6 +160,18 @@ function _get(proxyPath, hubPath, timeoutMs) {
return _hubGet(hubPath, timeoutMs);
}
+// Coerce an order `budget` input. The previous `Number(x) || 10` form
+// treated 0 as missing and silently substituted the default, so an
+// explicit `budget: 0` request was sent as 10 instead of clamping to the
+// floor of 1. Mirrors the falsy-zero fix in
+// sessionHandler.js#normalizeCreatePayload: explicit undefined / null /
+// '' checks first, then `Number.isFinite` to guard the strict-zero case.
+function _coerceBudget(raw) {
+ if (raw === undefined || raw === null || raw === '') return 10;
+ const n = Number(raw);
+ return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 10;
+}
+
/**
* POST /a2a/atp/order -- place an ATP order with routing
* @param {object} opts
@@ -176,7 +188,7 @@ function placeOrder(opts) {
return _post('/atp/order', '/a2a/atp/order', {
sender_id: nodeId,
capabilities: opts.capabilities,
- budget: Math.max(1, Math.round(Number(opts.budget) || 10)),
+ budget: _coerceBudget(opts.budget),
routing_mode: opts.routingMode || 'fastest',
verify_mode: opts.verifyMode || 'auto',
question: opts.question,
diff --git a/src/proxy/extensions/sessionHandler.js b/src/proxy/extensions/sessionHandler.js
index 24d033fe..9961803a 100644
--- a/src/proxy/extensions/sessionHandler.js
+++ b/src/proxy/extensions/sessionHandler.js
@@ -6,24 +6,105 @@
// delegation), shifting from passive Hub-orchestrated mode to agent-initiated
// mesh collaboration.
+// Session payload limits. Centralized so the route fallback and the
+// SessionHandler extension share one source of truth -- otherwise the
+// fallback path (when the extension is not registered) silently skips these
+// clamps/truncations and the wire contract diverges.
+const MAX_PARTICIPANTS = 20;
+const MIN_PARTICIPANTS = 2;
+const DEFAULT_PARTICIPANTS = 5;
+const MAX_INVITEES = 10;
+const MAX_PAYLOAD_BYTES = 16000;
+const MAX_SUMMARY_CHARS = 200;
+const VALID_ROLES = ['builder', 'planner', 'reviewer'];
+const DEFAULT_ROLE = 'builder';
+
+// Pure normalizers. Throw Error('...') on validation failure; the route
+// layer wraps the throw in a 400. Each accepts the wire shape (snake_case
+// keys) and returns the normalized payload (also snake_case). The handler
+// methods map their camelCase public API to snake_case before calling.
+
+// Normalize a /session/create body. Throws if `title` is missing.
+// `max_participants` is clamped to [MIN, MAX] (default DEFAULT).
+// `invite_node_ids` is sliced to the first MAX_INVITEES entries.
+function normalizeCreatePayload(body = {}) {
+ if (!body.title) throw new Error('title is required');
+ // Treat undefined/null/'' as missing (use default). Otherwise parse the
+ // value and clamp. `|| DEFAULT_PARTICIPANTS` would treat 0 as missing and
+ // silently change a legitimate 0 input into the default 5; the handler
+ // had this bug pre-refactor and the test suite caught it.
+ const raw = body.max_participants;
+ let num = Number(raw);
+ if (raw === undefined || raw === null || raw === '' || !Number.isFinite(num)) {
+ num = DEFAULT_PARTICIPANTS;
+ }
+ return {
+ title: body.title,
+ description: body.description || '',
+ invite_node_ids: Array.isArray(body.invite_node_ids) ? body.invite_node_ids.slice(0, MAX_INVITEES) : [],
+ max_participants: Math.max(MIN_PARTICIPANTS, Math.min(MAX_PARTICIPANTS, num)),
+ };
+}
+
+// Normalize a /session/message body. Throws if `session_id` is missing or
+// the serialized `payload` exceeds MAX_PAYLOAD_BYTES.
+function normalizeMessagePayload(body = {}) {
+ if (!body.session_id) throw new Error('session_id is required');
+ const safePayload = body.payload && typeof body.payload === 'object' ? body.payload : {};
+ const serialized = JSON.stringify(safePayload);
+ if (serialized.length > MAX_PAYLOAD_BYTES) throw new Error('payload too large (max 16KB)');
+ return {
+ session_id: body.session_id,
+ to_node_id: body.to_node_id || null,
+ msg_type: body.msg_type || 'context_update',
+ payload: safePayload,
+ };
+}
+
+// Normalize a /session/delegate body. Throws if `session_id` or `title` is
+// missing. `role` is whitelisted (unknown values fall back to DEFAULT_ROLE).
+function normalizeDelegatePayload(body = {}) {
+ if (!body.session_id) throw new Error('session_id is required');
+ if (!body.title) throw new Error('title is required');
+ return {
+ session_id: body.session_id,
+ to_node_id: body.to_node_id || null,
+ title: body.title,
+ description: body.description || '',
+ role: VALID_ROLES.includes(body.role) ? body.role : DEFAULT_ROLE,
+ };
+}
+
+// Normalize a /session/submit body. Throws if `session_id` or `task_id` is
+// missing. `summary` is truncated to MAX_SUMMARY_CHARS.
+function normalizeSubmitPayload(body = {}) {
+ if (!body.session_id) throw new Error('session_id is required');
+ if (!body.task_id) throw new Error('task_id is required');
+ const safeSummary = typeof body.summary === 'string' ? body.summary.slice(0, MAX_SUMMARY_CHARS) : '';
+ return {
+ session_id: body.session_id,
+ task_id: body.task_id,
+ result_asset_id: body.result_asset_id || null,
+ summary: safeSummary,
+ };
+}
+
class SessionHandler {
constructor({ store, logger } = {}) {
this.store = store;
this.logger = logger || console;
}
- createSession({ title, description, inviteNodeIds, maxParticipants } = {}) {
- if (!title) throw new Error('title is required');
-
+ createSession(input = {}) {
+ const payload = normalizeCreatePayload({
+ title: input.title,
+ description: input.description,
+ invite_node_ids: input.inviteNodeIds,
+ max_participants: input.maxParticipants,
+ });
return this.store.send({
type: 'session_create',
- payload: {
- title,
- description: description || '',
- invite_node_ids: Array.isArray(inviteNodeIds) ? inviteNodeIds.slice(0, 10) : [],
- max_participants: Math.max(2, Math.min(20, Number(maxParticipants) || 5)),
- created_at: new Date().toISOString(),
- },
+ payload: { ...payload, created_at: new Date().toISOString() },
priority: 'high',
});
}
@@ -54,62 +135,45 @@ class SessionHandler {
});
}
- sendMessage({ sessionId, toNodeId, msgType, payload } = {}) {
- if (!sessionId) throw new Error('sessionId is required');
-
- const safePayload = payload && typeof payload === 'object' ? payload : {};
- const serialized = JSON.stringify(safePayload);
- if (serialized.length > 16000) throw new Error('payload too large (max 16KB)');
-
+ sendMessage(input = {}) {
+ const payload = normalizeMessagePayload({
+ session_id: input.sessionId,
+ to_node_id: input.toNodeId,
+ msg_type: input.msgType,
+ payload: input.payload,
+ });
return this.store.send({
type: 'session_message',
- payload: {
- session_id: sessionId,
- to_node_id: toNodeId || null,
- msg_type: msgType || 'context_update',
- payload: safePayload,
- sent_at: new Date().toISOString(),
- },
+ payload: { ...payload, sent_at: new Date().toISOString() },
priority: 'normal',
});
}
- delegateSubtask({ sessionId, toNodeId, title, description, role } = {}) {
- if (!sessionId) throw new Error('sessionId is required');
- if (!title) throw new Error('title is required');
-
- const VALID_ROLES = ['builder', 'planner', 'reviewer'];
- const safeRole = VALID_ROLES.includes(role) ? role : 'builder';
-
+ delegateSubtask(input = {}) {
+ const payload = normalizeDelegatePayload({
+ session_id: input.sessionId,
+ to_node_id: input.toNodeId,
+ title: input.title,
+ description: input.description,
+ role: input.role,
+ });
return this.store.send({
type: 'session_delegate',
- payload: {
- session_id: sessionId,
- to_node_id: toNodeId || null,
- title,
- description: description || '',
- role: safeRole,
- delegated_at: new Date().toISOString(),
- },
+ payload: { ...payload, delegated_at: new Date().toISOString() },
priority: 'high',
});
}
- submitResult({ sessionId, taskId, resultAssetId, summary } = {}) {
- if (!sessionId) throw new Error('sessionId is required');
- if (!taskId) throw new Error('taskId is required');
-
- const safeSummary = typeof summary === 'string' ? summary.slice(0, 200) : '';
-
+ submitResult(input = {}) {
+ const payload = normalizeSubmitPayload({
+ session_id: input.sessionId,
+ task_id: input.taskId,
+ result_asset_id: input.resultAssetId,
+ summary: input.summary,
+ });
return this.store.send({
type: 'session_submit',
- payload: {
- session_id: sessionId,
- task_id: taskId,
- result_asset_id: resultAssetId || null,
- summary: safeSummary,
- submitted_at: new Date().toISOString(),
- },
+ payload: { ...payload, submitted_at: new Date().toISOString() },
priority: 'high',
});
}
@@ -138,4 +202,10 @@ class SessionHandler {
}
}
-module.exports = { SessionHandler };
+module.exports = {
+ SessionHandler,
+ normalizeCreatePayload,
+ normalizeMessagePayload,
+ normalizeDelegatePayload,
+ normalizeSubmitPayload,
+};
diff --git a/src/proxy/router/messages_route.js b/src/proxy/router/messages_route.js
index 651c82f4..5c4aba10 100644
--- a/src/proxy/router/messages_route.js
+++ b/src/proxy/router/messages_route.js
@@ -82,6 +82,10 @@ const KNOWN_BEDROCK_ALIASES = Object.freeze({
'sonnet/4/6': 'global.anthropic.claude-sonnet-4-6',
});
+// TODO: add 'sonnet/4/7' once Anthropic ships it on Bedrock — bare alias
+// (opus-4-7) or dated suffix (haiku-4-5)? Look up the actual ID before
+// pasting. See SKILL.md "Model Routing Ingress" > "Anthropic Messages API".
+
function canonicalizeForBedrock(modelId) {
const parsed = parseClaudeId(modelId);
if (!parsed) return modelId;
diff --git a/src/proxy/server/routes.js b/src/proxy/server/routes.js
index 25921ecd..9a897bc3 100644
--- a/src/proxy/server/routes.js
+++ b/src/proxy/server/routes.js
@@ -1,6 +1,21 @@
'use strict';
const { PROXY_PROTOCOL_VERSION, SCHEMA_VERSION } = require('../mailbox/store');
+const {
+ normalizeCreatePayload,
+ normalizeMessagePayload,
+ normalizeDelegatePayload,
+ normalizeSubmitPayload,
+} = require('../extensions/sessionHandler');
+
+// Run a session payload normalizer and convert any thrown error into a 400
+// response. Used by both the handler path (so handler errors surface as 400
+// instead of 500) and the fallback path (so missing/clamped fields are
+// rejected at the route boundary, not silently passed through to the store).
+function normalizeOr400(normalize, body) {
+ try { return normalize(body); }
+ catch (e) { throw Object.assign(new Error(e.message), { statusCode: 400 }); }
+}
function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
const {
@@ -270,25 +285,17 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
// -- Session (Collaboration) --
'POST /session/create': async ({ body }) => {
- if (!body.title) throw Object.assign(new Error('title is required'), { statusCode: 400 });
+ const payload = normalizeOr400(normalizeCreatePayload, body);
if (sessionHandler) {
const result = sessionHandler.createSession({
- title: body.title,
- description: body.description,
- inviteNodeIds: body.invite_node_ids,
- maxParticipants: body.max_participants,
+ title: payload.title,
+ description: payload.description,
+ inviteNodeIds: payload.invite_node_ids,
+ maxParticipants: payload.max_participants,
});
return { body: result };
}
- const result = store.send({
- type: 'session_create',
- payload: {
- title: body.title,
- description: body.description || '',
- invite_node_ids: body.invite_node_ids || [],
- max_participants: body.max_participants || 5,
- },
- });
+ const result = store.send({ type: 'session_create', payload });
return { body: result };
},
@@ -313,77 +320,48 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
},
'POST /session/message': async ({ body }) => {
- if (!body.session_id) throw Object.assign(new Error('session_id is required'), { statusCode: 400 });
+ const payload = normalizeOr400(normalizeMessagePayload, body);
if (sessionHandler) {
const result = sessionHandler.sendMessage({
- sessionId: body.session_id,
- toNodeId: body.to_node_id,
- msgType: body.msg_type,
- payload: body.payload,
+ sessionId: payload.session_id,
+ toNodeId: payload.to_node_id,
+ msgType: payload.msg_type,
+ payload: payload.payload,
});
return { body: result };
}
- const result = store.send({
- type: 'session_message',
- payload: {
- session_id: body.session_id,
- to_node_id: body.to_node_id || null,
- msg_type: body.msg_type || 'context_update',
- payload: body.payload || {},
- },
- });
+ const result = store.send({ type: 'session_message', payload });
return { body: result };
},
'POST /session/delegate': async ({ body }) => {
- if (!body.session_id) throw Object.assign(new Error('session_id is required'), { statusCode: 400 });
- if (!body.title) throw Object.assign(new Error('title is required'), { statusCode: 400 });
+ const payload = normalizeOr400(normalizeDelegatePayload, body);
if (sessionHandler) {
const result = sessionHandler.delegateSubtask({
- sessionId: body.session_id,
- toNodeId: body.to_node_id,
- title: body.title,
- description: body.description,
- role: body.role,
+ sessionId: payload.session_id,
+ toNodeId: payload.to_node_id,
+ title: payload.title,
+ description: payload.description,
+ role: payload.role,
});
return { body: result };
}
- const result = store.send({
- type: 'session_delegate',
- payload: {
- session_id: body.session_id,
- to_node_id: body.to_node_id || null,
- title: body.title,
- description: body.description || '',
- role: body.role || 'builder',
- },
- priority: 'high',
- });
+ const result = store.send({ type: 'session_delegate', payload, priority: 'high' });
return { body: result };
},
'POST /session/submit': async ({ body }) => {
- if (!body.session_id) throw Object.assign(new Error('session_id is required'), { statusCode: 400 });
- if (!body.task_id) throw Object.assign(new Error('task_id is required'), { statusCode: 400 });
+ const payload = normalizeOr400(normalizeSubmitPayload, body);
if (sessionHandler) {
const result = sessionHandler.submitResult({
- sessionId: body.session_id,
- taskId: body.task_id,
- resultAssetId: body.result_asset_id,
- summary: body.summary,
+ sessionId: payload.session_id,
+ taskId: payload.task_id,
+ resultAssetId: payload.result_asset_id,
+ summary: payload.summary,
});
return { body: result };
}
- const result = store.send({
- type: 'session_submit',
- payload: {
- session_id: body.session_id,
- task_id: body.task_id,
- result_asset_id: body.result_asset_id || null,
- summary: body.summary || '',
- },
- priority: 'high',
- });
+ const result = store.send({ type: 'session_submit', payload, priority: 'high' });
return { body: result };
},
diff --git a/test/bedrock-alias-watch.test.js b/test/bedrock-alias-watch.test.js
new file mode 100644
index 00000000..fb4bce5a
--- /dev/null
+++ b/test/bedrock-alias-watch.test.js
@@ -0,0 +1,546 @@
+'use strict';
+
+// Node --test port of the bash test harness for evolver/scripts/bedrock-alias-watch.sh.
+//
+// Each test case spawns the bash script as a subprocess with a controlled
+// environment, points it at a tmp-dir state file + a file://-URL mock
+// AWS doc, and captures the result via a local Slack receiver (an
+// http.createServer instance bound to 127.0.0.1).
+//
+// Same coverage as the bash version — 11 it() blocks across 9 conceptual
+// runs (3b and 8b are idempotency re-runs of runs 3 and 8).
+//
+// Cleanup pattern: every test uses
+// let result;
+// try { result = await runWatch({...}); /* assertions */ }
+// finally { if (result) await result.cleanup(); }
+// so a throw from runWatch() (port-in-use, disk full, bash not on PATH)
+// doesn't NPE on `result.cleanup()` in the finally block.
+
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawn } = require('node:child_process');
+const { mkdtemp, writeFile, readFile, rm, mkdir } = require('node:fs/promises');
+const { tmpdir } = require('node:os');
+const { join } = require('node:path');
+const http = require('node:http');
+
+const SCRIPT_PATH = join(__dirname, '..', 'scripts', 'bedrock-alias-watch.sh');
+
+// Standard 3-key mock for KNOWN_BEDROCK_ALIASES — shared across all runs.
+const MOCK_JS = `const KNOWN_BEDROCK_ALIASES = Object.freeze({
+ 'opus/4/7': 'global.anthropic.claude-opus-4-7',
+ 'haiku/4/5': 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'sonnet/4/6': 'global.anthropic.claude-sonnet-4-6',
+});
+`;
+
+// JP_MOCK_JS is the trailing-comma entry + closing brace that gets injected
+// into MOCK_JS via .replace(/\}\);\n?$/, JP_MOCK_JS) for Run 11's "JP entry
+// present" sub-case. Used to silence the direction-B smoke check WARN when
+// the table is extended to match a new prefix in the env var.
+const JP_MOCK_JS = ` 'opus-jp/4/7': 'jp.anthropic.claude-opus-4-7',
+});`;
+
+// Start a Slack receiver on a random localhost port. Each POST body's
+// raw bytes are appended to `requests`. Returns a `close()` function
+// the caller MUST call to release the port.
+function startSlackReceiver() {
+ return new Promise((resolve, reject) => {
+ const requests = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ requests.push(Buffer.concat(chunks).toString('utf8'));
+ res.writeHead(200);
+ res.end('ok');
+ });
+ req.on('error', () => { /* ignore client-side errors */ });
+ });
+ server.on('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ resolve({
+ port,
+ requests,
+ close: () => new Promise((r) => server.close(() => r())),
+ });
+ });
+ });
+}
+
+// Spawn the watch script with the given mock HTML, optional pre-seeded
+// state, and extra env vars. Returns {code, stdout, stderr, requests,
+// finalState, stateFile, cleanup}. The caller MUST await `cleanup()`
+// to release the port and remove the tmp dir.
+async function runWatch({ mockHtml, preState, mockJs = MOCK_JS, extraEnv = {} }) {
+ // Accumulate cleanup functors as resources are created. If anything
+ // between `mkdtemp` and a successful return throws, we run them all
+ // and re-throw — the caller never sees a half-initialized result.
+ const cleanups = [];
+ const register = (fn) => { cleanups.push(fn); };
+ const runCleanups = async () => {
+ while (cleanups.length) {
+ const fn = cleanups.pop();
+ try { await fn(); } catch (_) { /* best-effort */ }
+ }
+ };
+
+ const testRoot = await mkdtemp(join(tmpdir(), 'bedrock-alias-watch-'));
+ register(() => rm(testRoot, { recursive: true, force: true }));
+ const stateDir = join(testRoot, 'state');
+ await mkdir(stateDir, { recursive: true });
+ const stateFile = join(stateDir, 'bedrock-alias-watch.json');
+ const jsPath = join(testRoot, 'messages_route.js');
+ const htmlPath = join(testRoot, 'aws.html');
+ await writeFile(jsPath, mockJs);
+ await writeFile(htmlPath, mockHtml);
+ if (preState !== undefined) {
+ await writeFile(stateFile, JSON.stringify(preState));
+ }
+
+ let code, stdout, stderr, slack, finalState = null;
+ try {
+ slack = await startSlackReceiver();
+ register(() => slack.close());
+
+ ({ code, stdout, stderr } = await new Promise((resolve, reject) => {
+ const child = spawn('bash', [SCRIPT_PATH], {
+ env: {
+ ...process.env,
+ STATE_DIR: stateDir,
+ MESSAGES_ROUTE_FILE: jsPath,
+ AWS_BEDROCK_URL: `file://${htmlPath}`,
+ SLACK_WEBHOOK_URL: `http://127.0.0.1:${slack.port}/slack`,
+ ...extraEnv,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let _stdout = '';
+ let _stderr = '';
+ child.stdout.on('data', (c) => { _stdout += c; });
+ child.stderr.on('data', (c) => { _stderr += c; });
+ child.on('error', reject);
+ child.on('close', (code) => resolve({ code, stdout: _stdout, stderr: _stderr }));
+ }));
+
+ // Read the final state file. Returns null if the script didn't
+ // create it (DRY_RUN, AWS fetch fail, etc.).
+ try {
+ finalState = JSON.parse(await readFile(stateFile, 'utf8'));
+ } catch (_) { /* file may not exist */ }
+ } catch (err) {
+ // Anything between mkdtemp and the successful return failed (EADDRINUSE,
+ // bash not on PATH, spawn EACCES, etc.). Clean up everything we
+ // created and re-throw so the test's `finally` doesn't have to
+ // handle a half-initialized result.
+ await runCleanups();
+ throw err;
+ }
+
+ return {
+ code, stdout, stderr,
+ requests: slack.requests,
+ finalState,
+ stateFile,
+ cleanup: runCleanups,
+ };
+}
+
+// Helper: parse the last Slack POST body as JSON. Returns null if no
+// posts were made or the last body wasn't valid JSON.
+function lastSlackPayload(requests) {
+ if (requests.length === 0) return null;
+ const raw = requests[requests.length - 1];
+ if (!raw) return null;
+ try { return JSON.parse(raw); } catch (_) { return null; }
+}
+
+describe('bedrock-alias-watch.sh', () => {
+ // --- Run 1: first run should detect sonnet-4-7 as new and post to Slack.
+ // us.*-prefixed opus-4-7 is the regional sibling of an existing key,
+ // so it should NOT appear in the alert.
+ it('Run 1: first run detects sonnet-4-7 as new, us.* opus-4-7 suppressed', async () => {
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'us.anthropic.claude-opus-4-7-20251001-v1:0',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'meta.llama3-70b-instruct-v1:0',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml });
+ assert.equal(result.code, 0, `script exited non-zero: ${result.stderr}`);
+ const payload = lastSlackPayload(result.requests);
+ assert.ok(payload, 'expected at least 1 Slack post');
+ assert.match(payload.text, /sonnet\/4\/7/);
+ assert.doesNotMatch(payload.text, /opus\/4\/7/);
+ assert.match(payload.text, /KNOWN_BEDROCK_ALIASES/);
+ // State file: 4 seen_keys (3 known + sonnet-4-7), 0 seen_dated_ids.
+ assert.equal(result.finalState.seen_keys.length, 4);
+ assert.ok(result.finalState.seen_keys.includes('sonnet/4/7'));
+ assert.equal(result.finalState.seen_dated_ids.length, 0);
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 2: idempotency — same fixture, no new Slack post.
+ // Runs the script twice: first populates state, second uses it
+ // as preState to verify idempotency.
+ it('Run 2: re-run with same fixture is idempotent', async () => {
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'us.anthropic.claude-opus-4-7-20251001-v1:0',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ '',
+ ].join('\n');
+
+ let first = null;
+ let second = null;
+ let preState = null;
+ try {
+ // First run populates state.
+ first = await runWatch({ mockHtml });
+ assert.equal(first.code, 0, `first run exited non-zero: ${first.stderr}`);
+ assert.equal(first.requests.length, 1, 'first run should post 1 Slack message');
+ preState = first.finalState;
+ assert.ok(preState, 'first run should create state file');
+ assert.ok(preState.seen_keys.includes('sonnet/4/7'));
+ // Release the first run's resources before starting the second.
+ await first.cleanup();
+ first = null;
+
+ // Second run with same fixture + pre-seeded state — no new post.
+ second = await runWatch({ mockHtml, preState });
+ assert.equal(second.code, 0, `second run exited non-zero: ${second.stderr}`);
+ assert.equal(second.requests.length, 0, 'second run should not post to Slack');
+ } finally {
+ if (first) await first.cleanup();
+ if (second) await second.cleanup();
+ }
+ });
+
+ // --- Run 3: AWS adds a new family (sonnet-4-8) AND a dated revision
+ // (haiku-4-5-20251201). Expect ONE Slack post that mentions both.
+ // preState mirrors what Run 1 produced (3 known keys + sonnet-4-7),
+ // so sonnet-4-8 is the new family and the dated haiku revision
+ // hasn't been seen yet.
+ it('Run 3: AWS adds sonnet-4-8 + dated haiku revision, single post mentions both with was/now', async () => {
+ const preState = {
+ last_run: '2026-07-02T00:00:00Z',
+ seen_keys: ['opus/4/7', 'haiku/4/5', 'sonnet/4/6', 'sonnet/4/7'],
+ seen_dated_ids: [],
+ seen_retired: [],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'us.anthropic.claude-opus-4-7-20251001-v1:0',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'global.anthropic.claude-sonnet-4-8',
+ 'global.anthropic.claude-haiku-4-5-20251201-v1:0',
+ 'meta.llama3-70b-instruct-v1:0',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.code, 0, `script exited non-zero: ${result.stderr}`);
+ assert.equal(result.requests.length, 1, 'expected exactly 1 Slack post');
+ const payload = JSON.parse(result.requests[0]);
+ assert.match(payload.text, /sonnet\/4\/8/, 'should mention new family');
+ assert.match(payload.text, /20251201-v1:0/, 'should mention NEW dated suffix');
+ assert.match(payload.text, /20251001-v1:0/, 'should mention OLD dated suffix (was/now)');
+ assert.match(payload.text, /haiku\/4\/5/, 'should mention canon (haiku/4/5)');
+ assert.match(payload.text, /dated revision/, 'should have "dated revision" header');
+ assert.doesNotMatch(payload.text, /sonnet\/4\/7/, 'should NOT re-alert sonnet/4/7');
+ // State tracks the new dated ID + the new family.
+ assert.ok(result.finalState.seen_dated_ids.includes('global.anthropic.claude-haiku-4-5-20251201-v1:0'));
+ assert.ok(result.finalState.seen_keys.includes('sonnet/4/8'));
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 3b: idempotency for the dated revision — re-run with same
+ // fixture, no new post.
+ it('Run 3b: re-run after dated-revision alert is idempotent', async () => {
+ const preState = {
+ last_run: '2026-07-02T00:00:00Z',
+ seen_keys: ['opus/4/7', 'haiku/4/5', 'sonnet/4/6', 'sonnet/4/7', 'sonnet/4/8'],
+ seen_dated_ids: ['global.anthropic.claude-haiku-4-5-20251201-v1:0'],
+ seen_retired: [],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'global.anthropic.claude-sonnet-4-8',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.requests.length, 0, 'dated revision should not re-alert');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 4: DRY_RUN=1 should print but not post / not update state.
+ it('Run 4: DRY_RUN=1 prints to stderr but does not post or persist', async () => {
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'global.anthropic.claude-sonnet-4-8',
+ 'global.anthropic.claude-haiku-4-5-20251201-v1:0',
+ 'global.anthropic.claude-opus-4-9',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, extraEnv: { DRY_RUN: '1' } });
+ assert.equal(result.code, 0);
+ assert.equal(result.requests.length, 0, 'DRY_RUN should not post to Slack');
+ assert.match(result.stderr, /opus\/4\/9/, 'DRY_RUN should print new key to stderr');
+ // State file should NOT be created in DRY_RUN mode (script exits
+ // before step 6).
+ assert.equal(result.finalState, null, 'DRY_RUN should not create state file');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 5: AWS fetch fails → script exits 0 + state NOT updated.
+ it('Run 5: AWS fetch fails, exits 0, no post, no state update, logs WARN', async () => {
+ let result;
+ try {
+ result = await runWatch({
+ mockHtml: '', // not used — fetch fails before reading
+ extraEnv: { AWS_BEDROCK_URL: 'file:///nonexistent-path-that-does-not-exist' },
+ });
+ assert.equal(result.code, 0, 'fetch-failure path should exit 0');
+ assert.equal(result.requests.length, 0, 'fetch failure should not post to Slack');
+ assert.match(result.stderr, /AWS fetch failed/, 'should log AWS fetch failed');
+ assert.equal(result.finalState, null, 'fetch failure should not create state file');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 6: SLACK_WEBHOOK_URL unset → new IDs land on stderr, state still updates.
+ it('Run 6: SLACK_WEBHOOK_URL unset writes new IDs to stderr + still persists state', async () => {
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'global.anthropic.claude-sonnet-4-10',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, extraEnv: { SLACK_WEBHOOK_URL: '' } });
+ assert.equal(result.code, 0);
+ assert.equal(result.requests.length, 0, 'unset Slack should not post');
+ assert.match(result.stderr, /sonnet\/4\/10/, 'should log sonnet/4/10 to stderr');
+ assert.match(result.stderr, /SLACK_WEBHOOK_URL unset/, 'should log the unset message');
+ // State should still be updated with the new key.
+ assert.ok(result.finalState, 'unset Slack should still create state file');
+ assert.ok(result.finalState.seen_keys.includes('sonnet/4/10'));
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 7: backwards-compat — round-1 `seen_ids` state file is loaded
+ // and suppresses alerts for both sonnet-4-7 + opus-4-9 (0 Slack posts).
+ it('Run 7: backwards-compat — round-1 `seen_ids` state file suppresses alerts + migrates to `seen_keys`', async () => {
+ const preState = {
+ last_run: '2026-01-01T00:00:00Z',
+ seen_ids: ['sonnet/4/7', 'opus/4/9'],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ 'global.anthropic.claude-sonnet-4-7',
+ 'global.anthropic.claude-opus-4-9',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.code, 0);
+ assert.equal(result.requests.length, 0, 'backwards-compat should suppress sonnet-4-7 + opus-4-9');
+ // State should be rewritten in the new format + old seen_ids migrated.
+ assert.ok(result.finalState, 'state file should be rewritten');
+ assert.ok(result.finalState.seen_keys.includes('sonnet/4/7'),
+ 'seen_ids sonnet/4/7 should be migrated to seen_keys');
+ assert.ok(result.finalState.seen_keys.includes('opus/4/9'),
+ 'seen_ids opus/4/9 should be migrated to seen_keys');
+ assert.ok('seen_dated_ids' in result.finalState,
+ 'rewritten state file should have seen_dated_ids field');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 8: retirement — KNOWN has sonnet-4-6, AWS no longer lists it.
+ // Expected: 1 Slack post with a "retired" section for sonnet/4/6,
+ // and the state file's seen_retired tracks it.
+ it('Run 8: AWS doc removes sonnet-4-6, posts retirement alert with full ID context', async () => {
+ const preState = {
+ last_run: '2026-07-02T00:00:00Z',
+ seen_keys: ['opus/4/7', 'haiku/4/5', 'sonnet/4/6', 'sonnet/4/7', 'sonnet/4/8'],
+ seen_dated_ids: ['global.anthropic.claude-haiku-4-5-20251201-v1:0'],
+ seen_retired: [],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-7',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.code, 0);
+ assert.equal(result.requests.length, 1, 'expected exactly 1 Slack post');
+ const payload = JSON.parse(result.requests[0]);
+ assert.match(payload.text, /sonnet\/4\/6/, 'should mention retired canon');
+ assert.match(payload.text, /retired/, 'should have "retired" section header');
+ assert.match(payload.text, /global\.anthropic\.claude-sonnet-4-6/,
+ 'should include the operator-context full ID');
+ assert.doesNotMatch(payload.text, /sonnet\/4\/7/, 'should NOT re-alert sonnet/4/7');
+ // State should track the retirement.
+ assert.ok(result.finalState.seen_retired.includes('sonnet/4/6'));
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 8b: idempotency — re-run with same fixture, no new Slack post.
+ it('Run 8b: re-run after retirement alert is idempotent', async () => {
+ const preState = {
+ last_run: '2026-07-02T00:00:00Z',
+ seen_keys: ['opus/4/7', 'haiku/4/5', 'sonnet/4/6', 'sonnet/4/7', 'sonnet/4/8'],
+ seen_dated_ids: [],
+ seen_retired: ['sonnet/4/6'],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-7',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.requests.length, 0, 'retirement should not re-alert');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 9: came back — state file has sonnet-4-6 in seen_retired,
+ // but AWS now lists it again. Expected: no retirement alert, AND
+ // seen_retired is cleared so a future retirement re-alerts.
+ it('Run 9: sonnet-4-6 comes back to AWS, no alert, seen_retired cleared', async () => {
+ const preState = {
+ last_run: '2026-01-01T00:00:00Z',
+ seen_keys: [],
+ seen_dated_ids: [],
+ seen_retired: ['sonnet/4/6'],
+ };
+ const mockHtml = [
+ '',
+ 'global.anthropic.claude-opus-4-7',
+ 'global.anthropic.claude-haiku-4-5-20251001-v1:0',
+ 'global.anthropic.claude-sonnet-4-6',
+ '',
+ ].join('\n');
+
+ let result;
+ try {
+ result = await runWatch({ mockHtml, preState });
+ assert.equal(result.code, 0);
+ assert.equal(result.requests.length, 0, 'came back should not trigger retirement alert');
+ assert.equal(result.finalState.seen_retired.length, 0,
+ 'seen_retired should be cleared after came back');
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 10: BEDROCK_REGIONAL_PREFIXES drops 'global'. MOCK_JS has 3
+ // global.* entries -- direction A fires (3 IDs flagged in stderr).
+ // Direction B is silent (env \ default = {} since us|eu|ap is a
+ // strict subset of the documented default 'global|us|eu|ap').
+ it('Run 10: env drops global, direction A fires 3 IDs, direction B silent', async () => {
+ let result;
+ try {
+ result = await runWatch({
+ mockHtml: '',
+ extraEnv: { BEDROCK_REGIONAL_PREFIXES: 'us|eu|ap' },
+ });
+ assert.equal(result.code, 0);
+ // Direction A: exactly 3 global.* IDs flagged.
+ assert.match(result.stderr,
+ /WARN: 3 known alias\(es\) have a regional prefix not in/);
+ assert.match(result.stderr, /global\.anthropic\.claude-opus-4-7/);
+ assert.match(result.stderr, /global\.anthropic\.claude-haiku-4-5-20251001-v1:0/);
+ assert.match(result.stderr, /global\.anthropic\.claude-sonnet-4-6/);
+ // Direction B should NOT fire (env \ default is empty).
+ assert.doesNotMatch(result.stderr,
+ /env-var regional prefix.+non-default/);
+ } finally { if (result) await result.cleanup(); }
+ });
+
+ // --- Run 11: BEDROCK_REGIONAL_PREFIXES adds 'jp'. Sub-case A: env
+ // extends to jp but the table does NOT have a jp.* entry -- direction
+ // B fires (1 token flagged: jp). Sub-case B: env extends to jp AND the
+ // table has a jp.* entry via JP_MOCK_JS injection -- direction B is
+ // silenced (legitimate extension, not a typo). Distinct from Run 10
+ // which exercises direction A.
+ it('Run 11: env adds jp, direction B fires 1 (table lacks jp) + silent (table has jp via JP_MOCK_JS)', async () => {
+ let result_no_jp, result_with_jp;
+ try {
+ // Sub-case A: env extends to jp, but MOCK_JS has no jp entry.
+ result_no_jp = await runWatch({
+ mockHtml: '',
+ extraEnv: { BEDROCK_REGIONAL_PREFIXES: 'global|us|eu|ap|jp' },
+ });
+ assert.equal(result_no_jp.code, 0);
+ assert.match(result_no_jp.stderr,
+ /WARN: 1 env-var regional prefix.+non-default/);
+ assert.match(result_no_jp.stderr, /\bjp\b/);
+ // Direction A should NOT fire (both global and jp prefixes are in env).
+ assert.doesNotMatch(result_no_jp.stderr,
+ /known alias\(es\) have a regional prefix not in/);
+
+ // Sub-case B: env extends to jp AND the table has a jp entry.
+ // The smoke check direction B is silenced by the matching data.
+ const mockJsWithJp = MOCK_JS.replace(/\}\);\n?$/, JP_MOCK_JS);
+ result_with_jp = await runWatch({
+ mockHtml: '',
+ mockJs: mockJsWithJp,
+ extraEnv: { BEDROCK_REGIONAL_PREFIXES: 'global|us|eu|ap|jp' },
+ });
+ assert.equal(result_with_jp.code, 0);
+ assert.doesNotMatch(result_with_jp.stderr,
+ /env-var regional prefix.+non-default/);
+ } finally {
+ if (result_no_jp) await result_no_jp.cleanup();
+ if (result_with_jp) await result_with_jp.cleanup();
+ }
+ });
+});
diff --git a/test/extensions.test.js b/test/extensions.test.js
index 95f3ffdd..27b393ba 100644
--- a/test/extensions.test.js
+++ b/test/extensions.test.js
@@ -9,7 +9,13 @@ const path = require('path');
const { MailboxStore } = require('../src/proxy/mailbox/store');
const { SkillUpdater } = require('../src/proxy/extensions/skillUpdater');
const { DmHandler } = require('../src/proxy/extensions/dmHandler');
-const { SessionHandler } = require('../src/proxy/extensions/sessionHandler');
+const {
+ SessionHandler,
+ normalizeCreatePayload,
+ normalizeMessagePayload,
+ normalizeDelegatePayload,
+ normalizeSubmitPayload,
+} = require('../src/proxy/extensions/sessionHandler');
function tmpDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'extensions-test-'));
@@ -286,3 +292,102 @@ describe('SessionHandler', () => {
assert.ok(sessions.length > 0);
});
});
+
+describe('Session payload normalizers', () => {
+ describe('normalizeCreatePayload', () => {
+ it('throws on missing title', () => {
+ assert.throws(() => normalizeCreatePayload({}), /title/);
+ assert.throws(() => normalizeCreatePayload({ description: 'x' }), /title/);
+ });
+
+ it('clamps max_participants to [2, 20]', () => {
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: 1 }).max_participants, 2);
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: 100 }).max_participants, 20);
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: 5 }).max_participants, 5);
+ });
+
+ it('defaults max_participants to 5 when missing or non-numeric', () => {
+ assert.equal(normalizeCreatePayload({ title: 't' }).max_participants, 5);
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: 'abc' }).max_participants, 5);
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: null }).max_participants, 5);
+ assert.equal(normalizeCreatePayload({ title: 't', max_participants: '' }).max_participants, 5);
+ });
+
+ it('slices invite_node_ids to first 10', () => {
+ const ids = Array.from({ length: 15 }, (_, i) => 'n' + i);
+ const r = normalizeCreatePayload({ title: 't', invite_node_ids: ids });
+ assert.equal(r.invite_node_ids.length, 10);
+ assert.deepEqual(r.invite_node_ids, ids.slice(0, 10));
+ });
+
+ it('defaults invite_node_ids to [] when not an array', () => {
+ assert.deepEqual(normalizeCreatePayload({ title: 't' }).invite_node_ids, []);
+ assert.deepEqual(normalizeCreatePayload({ title: 't', invite_node_ids: 'bad' }).invite_node_ids, []);
+ });
+
+ it('defaults description to empty string', () => {
+ assert.equal(normalizeCreatePayload({ title: 't' }).description, '');
+ });
+ });
+
+ describe('normalizeMessagePayload', () => {
+ it('throws on missing session_id', () => {
+ assert.throws(() => normalizeMessagePayload({}), /session_id/);
+ });
+
+ it('throws when payload exceeds 16KB serialized', () => {
+ const big = { data: 'x'.repeat(20000) };
+ assert.throws(() => normalizeMessagePayload({ session_id: 's', payload: big }), /too large/);
+ });
+
+ it('defaults payload to {} when not an object', () => {
+ assert.deepEqual(normalizeMessagePayload({ session_id: 's' }).payload, {});
+ assert.deepEqual(normalizeMessagePayload({ session_id: 's', payload: 'bad' }).payload, {});
+ });
+
+ it('defaults msg_type to context_update and to_node_id to null', () => {
+ const r = normalizeMessagePayload({ session_id: 's' });
+ assert.equal(r.msg_type, 'context_update');
+ assert.equal(r.to_node_id, null);
+ });
+ });
+
+ describe('normalizeDelegatePayload', () => {
+ it('throws on missing session_id or title', () => {
+ assert.throws(() => normalizeDelegatePayload({}), /session_id/);
+ assert.throws(() => normalizeDelegatePayload({ session_id: 's' }), /title/);
+ });
+
+ it('whitelists role to builder/planner/reviewer', () => {
+ assert.equal(normalizeDelegatePayload({ session_id: 's', title: 't', role: 'builder' }).role, 'builder');
+ assert.equal(normalizeDelegatePayload({ session_id: 's', title: 't', role: 'planner' }).role, 'planner');
+ assert.equal(normalizeDelegatePayload({ session_id: 's', title: 't', role: 'reviewer' }).role, 'reviewer');
+ });
+
+ it('falls back to builder for invalid or missing role', () => {
+ assert.equal(normalizeDelegatePayload({ session_id: 's', title: 't', role: 'invalid' }).role, 'builder');
+ assert.equal(normalizeDelegatePayload({ session_id: 's', title: 't' }).role, 'builder');
+ });
+ });
+
+ describe('normalizeSubmitPayload', () => {
+ it('throws on missing session_id or task_id', () => {
+ assert.throws(() => normalizeSubmitPayload({}), /session_id/);
+ assert.throws(() => normalizeSubmitPayload({ session_id: 's' }), /task_id/);
+ });
+
+ it('truncates summary to 200 chars', () => {
+ const r = normalizeSubmitPayload({ session_id: 's', task_id: 't', summary: 'x'.repeat(300) });
+ assert.equal(r.summary.length, 200);
+ });
+
+ it('defaults summary to empty string when not a string', () => {
+ assert.equal(normalizeSubmitPayload({ session_id: 's', task_id: 't' }).summary, '');
+ assert.equal(normalizeSubmitPayload({ session_id: 's', task_id: 't', summary: 123 }).summary, '');
+ });
+
+ it('defaults result_asset_id to null', () => {
+ assert.equal(normalizeSubmitPayload({ session_id: 's', task_id: 't' }).result_asset_id, null);
+ });
+ });
+});
diff --git a/test/proxyServer.test.js b/test/proxyServer.test.js
index 0fd386f1..a4e8a80d 100644
--- a/test/proxyServer.test.js
+++ b/test/proxyServer.test.js
@@ -496,6 +496,96 @@ describe('ProxyHttpServer', () => {
'small bodies must still pass: got ' + res.status);
});
});
+
+ // Session routes exercised in fallback mode (buildRoutes called with
+ // `extensions: {}`, so the route's `if (sessionHandler)` branch is skipped
+ // and the new shared normalizers run instead). These tests verify that the
+ // fallback path applies the same input validation as the SessionHandler
+ // extension, closing the wire-contract inconsistency.
+ describe('Session routes (fallback path)', () => {
+ it('POST /session/create clamps max_participants to 20', async () => {
+ const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
+ title: 'Test',
+ max_participants: 100,
+ });
+ assert.equal(res.status, 200);
+ const msg = store.getById(res.body.message_id);
+ assert.equal(msg.payload.max_participants, 20);
+ });
+
+ it('POST /session/create clamps max_participants to minimum of 2', async () => {
+ const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
+ title: 'Test',
+ max_participants: 0,
+ });
+ assert.equal(res.status, 200);
+ const msg = store.getById(res.body.message_id);
+ assert.equal(msg.payload.max_participants, 2);
+ });
+
+ it('POST /session/create slices invite_node_ids to first 10', async () => {
+ const ids = Array.from({ length: 15 }, (_, i) => 'n' + i);
+ const res = await authedReq(`${baseUrl}/session/create`, 'POST', {
+ title: 'Test',
+ invite_node_ids: ids,
+ });
+ assert.equal(res.status, 200);
+ const msg = store.getById(res.body.message_id);
+ assert.equal(msg.payload.invite_node_ids.length, 10);
+ });
+
+ it('POST /session/create rejects missing title with 400', async () => {
+ const res = await authedReq(`${baseUrl}/session/create`, 'POST', {});
+ assert.equal(res.status, 400);
+ });
+
+ it('POST /session/join returns 400 on missing session_id', async () => {
+ const res = await authedReq(`${baseUrl}/session/join`, 'POST', {});
+ assert.equal(res.status, 400);
+ });
+
+ it('POST /session/leave returns 400 on missing session_id', async () => {
+ const res = await authedReq(`${baseUrl}/session/leave`, 'POST', {});
+ assert.equal(res.status, 400);
+ });
+
+ it('POST /session/message rejects oversized payload with 400', async () => {
+ const res = await authedReq(`${baseUrl}/session/message`, 'POST', {
+ session_id: 's1',
+ payload: { data: 'x'.repeat(20000) },
+ });
+ assert.equal(res.status, 400);
+ });
+
+ it('POST /session/delegate normalizes invalid role to builder', async () => {
+ const res = await authedReq(`${baseUrl}/session/delegate`, 'POST', {
+ session_id: 's1',
+ title: 'task',
+ role: 'invalid',
+ });
+ assert.equal(res.status, 200);
+ const msg = store.getById(res.body.message_id);
+ assert.equal(msg.payload.role, 'builder');
+ });
+
+ it('POST /session/submit truncates summary to 200 chars', async () => {
+ const res = await authedReq(`${baseUrl}/session/submit`, 'POST', {
+ session_id: 's1',
+ task_id: 't1',
+ summary: 'x'.repeat(300),
+ });
+ assert.equal(res.status, 200);
+ const msg = store.getById(res.body.message_id);
+ assert.equal(msg.payload.summary.length, 200);
+ });
+
+ it('POST /session/submit returns 400 on missing task_id', async () => {
+ const res = await authedReq(`${baseUrl}/session/submit`, 'POST', {
+ session_id: 's1',
+ });
+ assert.equal(res.status, 400);
+ });
+ });
});
describe('EvoMapProxy._buildBundleFromLooseAsset', () => {
diff --git a/test/routerCanonicalizeBedrock.test.js b/test/routerCanonicalizeBedrock.test.js
index b2e969e8..d6a0fb7a 100644
--- a/test/routerCanonicalizeBedrock.test.js
+++ b/test/routerCanonicalizeBedrock.test.js
@@ -16,6 +16,7 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
+const { request } = require('undici');
const {
buildMessagesHandler,
@@ -96,6 +97,84 @@ describe('canonicalizeForBedrock', () => {
'global.anthropic.claude-haiku-4-5-20251001-v1:0',
);
});
+
+ // Tripwire: when Anthropic ships sonnet-4-7 on Bedrock InvokeModel, this
+ // test fails until the operator adds the entry to KNOWN_BEDROCK_ALIASES.
+ // The probe fetches the AWS Bedrock "Supported foundation models" doc —
+ // which AWS maintains in lockstep with InvokeModel availability — and
+ // checks for the canonical alias. A real Bedrock InvokeModel probe would
+ // require AWS credentials + an enabled model + region, which is out of
+ // scope for a unit test; the docs page is the lightweight ground-truth
+ // source we tie the assertion to.
+ //
+ // Two failure modes this test guards against:
+ // 1. Operator adds a fake alias (typo or guessed ID) before Bedrock
+ // actually accepts it — the probe would say "not shipped", the
+ // assertion expects passthrough, the canonicalizer's returned
+ // fake ID trips the test.
+ // 2. Operator adds the right alias in the wrong format (dated suffix
+ // when bare is canonical, or `us.*` when `global.*` is) — the
+ // probe asserts the exact string, so a wrong format fails.
+ //
+ // On failure the message tells the operator exactly what to do: verify
+ // the alias on the AWS doc page, then add it to KNOWN_BEDROCK_ALIASES
+ // in the same format the probe found.
+ // AWS_BEDROCK_PROBE=0 opts the tripwire out of the live network probe
+ // (useful for fast local iteration or CI environments without outbound
+ // access to docs.aws.amazon.com). When disabled, the test assumes
+ // sonnet-4-7 has NOT shipped and asserts passthrough — same behavior
+ // as the network-unavailable fall-through path.
+ it("canonicalizeForBedrock('claude-sonnet-4-7') trips when AWS Bedrock docs list the alias (ground-truth for InvokeModel availability)", async (t) => {
+ const PROBE_DISABLED = process.env.AWS_BEDROCK_PROBE === '0';
+ const SONNET_4_7_BEDROCK_ID = 'global.anthropic.claude-sonnet-4-7';
+ const AWS_BEDROCK_DOCS_URL =
+ 'https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html';
+
+ let aliasShipped = false;
+ let probeNote = '';
+ if (PROBE_DISABLED) {
+ probeNote = 'AWS_BEDROCK_PROBE=0: probe skipped; assuming not shipped';
+ } else try {
+ const { statusCode, body } = await request(AWS_BEDROCK_DOCS_URL, {
+ headersTimeout: 5000,
+ bodyTimeout: 5000,
+ });
+ if (statusCode === 200) {
+ const html = await body.text();
+ aliasShipped = html.includes(SONNET_4_7_BEDROCK_ID);
+ probeNote = `AWS doc probe (${AWS_BEDROCK_DOCS_URL}): ` +
+ `${aliasShipped ? 'FOUND' : 'NOT FOUND'} \`${SONNET_4_7_BEDROCK_ID}\``;
+ } else {
+ probeNote = `AWS doc probe returned HTTP ${statusCode}; assuming not shipped`;
+ }
+ } catch (err) {
+ probeNote = `AWS doc probe unavailable (${err && err.message}); assuming not shipped`;
+ }
+ t.diagnostic(probeNote);
+
+ const actual = canonicalizeForBedrock('claude-sonnet-4-7');
+
+ if (aliasShipped) {
+ assert.equal(
+ actual,
+ SONNET_4_7_BEDROCK_ID,
+ `Anthropic has shipped \`${SONNET_4_7_BEDROCK_ID}\` on Bedrock InvokeModel ` +
+ `(verified at ${AWS_BEDROCK_DOCS_URL}), but canonicalizeForBedrock ` +
+ `does not return it. Add 'sonnet/4/7': '${SONNET_4_7_BEDROCK_ID}' to ` +
+ `KNOWN_BEDROCK_ALIASES in src/proxy/router/messages_route.js (verify ` +
+ `bare-vs-dated format from the AWS doc before pasting).`,
+ );
+ } else {
+ assert.equal(
+ actual,
+ 'claude-sonnet-4-7',
+ `Anthropic has NOT shipped sonnet-4-7 on Bedrock InvokeModel yet ` +
+ `(verified at ${AWS_BEDROCK_DOCS_URL}). canonicalizeForBedrock ` +
+ `must passthrough. When the probe shows it shipped, update ` +
+ `KNOWN_BEDROCK_ALIASES.`,
+ );
+ }
+ });
});
describe('supportsAdaptiveThinking', () => {