diff --git a/.github/workflows/logo-drift.yml b/.github/workflows/logo-drift.yml
new file mode 100644
index 0000000..d5219ff
--- /dev/null
+++ b/.github/workflows/logo-drift.yml
@@ -0,0 +1,58 @@
+name: logo drift
+
+on:
+ schedule:
+ - cron: "17 6 * * 1"
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "profile/assets/icons/**"
+ - "profile/README.md"
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - id: check
+ run: node profile/assets/icons/check-sources.mjs 2>&1 | tee report.txt
+ continue-on-error: true
+
+ # On a pull request the failed step is visible in the checks, so leave it
+ # there. On the schedule nobody is looking, and the fix is easy to forget,
+ # so it needs somewhere durable to sit.
+ - if: steps.check.outcome == 'failure' && github.event_name != 'pull_request'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ set -euo pipefail
+ existing=$(gh issue list --state open --label logo-drift --limit 1 --json number --jq '.[0].number // ""')
+ body=$(printf 'A logo on the org profile no longer matches the project it was copied from.\n\n```\n%s\n```\n\n%s\n' "$(cat report.txt)" "$RUN_URL")
+ if [ -n "$existing" ]; then
+ gh issue comment "$existing" --body "$body"
+ else
+ gh label create logo-drift --description "Copied project logos are out of date" --color d4c5f9 --force
+ gh issue create --title "Project logos are out of date" --label logo-drift --body "$body"
+ fi
+
+ # Close the issue once the copies are back in step, so an open one always
+ # means there is something to do.
+ - if: steps.check.outcome == 'success' && github.event_name != 'pull_request'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ for n in $(gh issue list --state open --label logo-drift --json number --jq '.[].number'); do
+ gh issue close "$n" --comment "Logos match their sources again."
+ done
+
+ - if: steps.check.outcome == 'failure'
+ run: exit 1
diff --git a/profile/README.md b/profile/README.md
index 4969d5e..5de1e3a 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -32,6 +32,8 @@ different layer of the stack.
| | **[ink](https://github.com/Ad-Astra-Computing/ink)** | An open protocol for signed agent-to-agent messages. The receiver verifies the envelope before it acts. |
| | **[ahd](https://github.com/Ad-Astra-Computing/ahd)** | Critiques of AI-generated design that anyone can re-run. Findings stay reproducible against a named, versioned rule set. |
| | **[nixpkgs](https://github.com/Ad-Astra-Computing/nixpkgs)** | The working fork for Trace, our research on AI-assisted CVE remediation. Nothing here is submitted upstream. |
+| | **[AER](https://aer.adastra.computer)** | A flight recorder for AI agents. Every run leaves a signed execution record, so what an agent did stays answerable after the fact. |
+| | **[Folio](https://receiptsofthought.com)** | A desktop writing app that keeps a tamper-evident record of how a piece was made. It emits open [receipts](https://receiptsofthought.com) anyone can verify. |
### Also here
diff --git a/profile/assets/icons/aer.svg b/profile/assets/icons/aer.svg
new file mode 100644
index 0000000..be21ff1
--- /dev/null
+++ b/profile/assets/icons/aer.svg
@@ -0,0 +1,6 @@
+
diff --git a/profile/assets/icons/check-sources.mjs b/profile/assets/icons/check-sources.mjs
new file mode 100644
index 0000000..3e17026
--- /dev/null
+++ b/profile/assets/icons/check-sources.mjs
@@ -0,0 +1,94 @@
+// Each icon here is a copy of a mark owned by another project. This checks the
+// copies against their sources so a redrawn mark does not sit stale on the org
+// profile indefinitely. It never writes: on a mismatch it reports which file to
+// re-copy from where, and exits non-zero.
+//
+// Icons with no reachable source are declared `unchecked` in sources.json and
+// reported as such. A missing entry is an error, so adding an icon without
+// deciding how it gets watched fails here rather than passing quietly.
+import { readFile, readdir } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const sources = JSON.parse(await readFile(join(HERE, "sources.json"), "utf8"));
+
+async function fetchText(url) {
+ // One retry, so a single 5xx on the weekly run does not read as drift.
+ let last;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 3000));
+ try {
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(20000) });
+ if (!res.ok) {
+ last = new Error(`HTTP ${res.status}`);
+ continue;
+ }
+ return await res.text();
+ } catch (err) {
+ last = err;
+ }
+ }
+ throw last;
+}
+
+const problems = [];
+
+const icons = (await readdir(HERE)).filter((f) => f.endsWith(".svg")).map((f) => f.slice(0, -4)).sort();
+
+for (const name of icons) {
+ const spec = sources[name];
+ if (spec === undefined) {
+ problems.push(`${name}: no entry in sources.json. Add one, or mark it "unchecked" with a reason.`);
+ continue;
+ }
+
+ if (spec.mode === "unchecked") {
+ console.log(`skipped ${name}: ${spec.reason}`);
+ continue;
+ }
+
+ const local = await readFile(join(HERE, `${name}.svg`), "utf8");
+
+ let upstream;
+ try {
+ upstream = await fetchText(spec.url);
+ } catch (err) {
+ // An unreachable source is its own problem: the check stops being able to
+ // answer whether the copy is current, which is the only thing it is for.
+ problems.push(`${name}: source unreachable, ${spec.url} (${err.message})`);
+ continue;
+ }
+
+ if (spec.mode === "bytes") {
+ if (local !== upstream) {
+ problems.push(
+ `${name}: ${name}.svg no longer matches ${spec.url}. Re-copy it, check it still reads at 20px on both GitHub themes, then commit.`,
+ );
+ }
+ } else if (spec.mode === "contains") {
+ if (!upstream.includes(spec.marker)) {
+ problems.push(
+ `${name}: ${spec.url} no longer contains the marker this copy was taken from. The mark was probably redrawn, so re-derive ${name}.svg from it.`,
+ );
+ }
+ } else {
+ problems.push(`${name}: unknown mode ${JSON.stringify(spec.mode)} in sources.json`);
+ }
+
+ console.log(`checked ${name} against ${spec.url}`);
+}
+
+for (const name of Object.keys(sources)) {
+ if (!icons.includes(name)) {
+ problems.push(`${name}: listed in sources.json but ${name}.svg does not exist.`);
+ }
+}
+
+if (problems.length > 0) {
+ console.error(`\n${problems.length} problem(s):`);
+ for (const p of problems) console.error(` ${p}`);
+ process.exit(1);
+}
+
+console.log(`\nall ${icons.length} icons accounted for`);
diff --git a/profile/assets/icons/folio.svg b/profile/assets/icons/folio.svg
new file mode 100644
index 0000000..c031f67
--- /dev/null
+++ b/profile/assets/icons/folio.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/profile/assets/icons/sources.json b/profile/assets/icons/sources.json
new file mode 100644
index 0000000..9e1a688
--- /dev/null
+++ b/profile/assets/icons/sources.json
@@ -0,0 +1,28 @@
+{
+ "vega-agent": {
+ "url": "https://raw.githubusercontent.com/Ad-Astra-Computing/vega-agent/main/.github/assets/vega-logo-light.svg",
+ "mode": "contains",
+ "marker": "M50 2 L58 42 L98 50 L58 58 L50 98 L42 58 L2 50 L42 42 Z",
+ "note": "vega-agent publishes only the large gradient logo, not the favicon this copy came from. The star outline is the part both files share, so this watches the path data instead of the whole file."
+ },
+ "ink": {
+ "url": "https://ink.tulpa.network/favicon.svg",
+ "mode": "bytes"
+ },
+ "ahd": {
+ "url": "https://ahd.adastra.computer/favicon.svg",
+ "mode": "bytes"
+ },
+ "nixpkgs": {
+ "url": "https://raw.githubusercontent.com/NixOS/nixos-artwork/master/logo/nix-snowflake-colours.svg",
+ "mode": "bytes"
+ },
+ "aer": {
+ "url": "https://aer.adastra.computer/favicon.svg",
+ "mode": "bytes"
+ },
+ "folio": {
+ "mode": "unchecked",
+ "reason": "Folio has no public repo or site, so there is no source this workflow can reach. Copied by hand from assets/logo-small.svg, the variant drawn for sub-64px use. Re-copy it when Folio gets a public home, and give it a url here."
+ }
+}