diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87701d6..cd8d902 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,6 +50,27 @@ jobs:
- name: Run tests
run: python3 -m pytest -v
+ # Event 179 — the app shell gets a build gate (E176 shipped it CI-blind;
+ # release-relevance ends that). Build only — launching a GUI on a runner
+ # proves nothing the compile doesn't.
+ app-build:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Cargo cache
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ app/target
+ key: cargo-${{ runner.os }}-${{ hashFiles('app/Cargo.lock') }}
+
+ - name: Build app shell
+ run: cd app && cargo build --release
+
# Event 177 — the distribution acceptance bar as CI: a clean macOS runner
# with nothing but Python must go wheel → install → doctor clean → sync →
# resolvable hooks → a hook actually firing, with ZERO repo checkout in
diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml
new file mode 100644
index 0000000..dcb7ac7
--- /dev/null
+++ b/.github/workflows/release-assets.yml
@@ -0,0 +1,62 @@
+# Release assets (E179): build the wheel and the .pkg and attach both to a
+# GitHub release.
+#
+# TRIGGER REALITY (the E163 deadlock class, documented not hidden):
+# release-please cuts releases with GITHUB_TOKEN, and GitHub deliberately
+# does not chain workflows off bot-token events — so `release: published`
+# only fires once the operator gives release-please a PAT. Until then,
+# `workflow_dispatch` is the working path: pick the tag, run, assets land.
+#
+# Signing: runners have no Developer ID cert; artifacts built here are the
+# UNSIGNED tier. Once the operator enrolls (Apple Developer, operator-only
+# gate), signing moves into scripts/build_app_pkg.sh via its env vars and a
+# certificate-import step here.
+
+name: release-assets
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to attach assets to (e.g. episteme-v1.10.0)"
+ required: true
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-attach:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Build wheel
+ run: python3 -m pip wheel . --no-deps -w dist_wheel
+
+ - name: Privacy gate (same contract as CI clean-install)
+ run: |
+ LISTING="$(python3 -m zipfile -l dist_wheel/*.whl)"
+ echo "$LISTING" | grep -q "_assets/core/hooks/workflow_guard.py" || { echo "assets missing"; exit 1; }
+ if echo "$LISTING" | grep -E "memory/global/(runtime_digest|operator_profile|agent_feedback|workflow_policy|cognitive_profile|overview|python_runtime_policy)\.md|skills/private|memory/(evolution|substrates)|\.jsonl|\.pem|\.key|\.p12"; then
+ echo "PRIVACY VIOLATION in release wheel"; exit 1; fi
+
+ - name: Build app + pkg
+ run: scripts/build_app_pkg.sh
+
+ - name: Attach assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }}
+ run: |
+ gh release upload "$TAG" dist_wheel/*.whl dist/Episteme.pkg --clobber
+ echo "attached: $(ls dist_wheel/*.whl) dist/Episteme.pkg → $TAG"
diff --git a/app/README.md b/app/README.md
index 0b8982d..565b5ce 100644
--- a/app/README.md
+++ b/app/README.md
@@ -29,7 +29,25 @@ scripts/build_app_pkg.sh
Requires Rust ≥ 1.85 (`edition = "2024"` in `app/Cargo.toml`); `app/` is not
built in CI — the build host's toolchain is the only gate.
-Produces `dist/Episteme.app` and an **unsigned** `dist/Episteme.pkg`
-(installs to /Applications). Unsigned is a conscious tier: it installs
-cleanly on the machine that built it; distribution-grade signing +
-notarization is a separate operator-gated decision recorded in the ledger.
+Produces `dist/Episteme.app` and `dist/Episteme.pkg` (installs to
+/Applications), with the icon regenerated on demand:
+`swift app/icon/make_icns.swift app/icon` (the committed `episteme.icns` is
+a reproducible artifact of that script — no design blob without a source).
+
+**Signing tiers** (E179 — the script is credential-ready and inert without
+certs; each tier upgrades by exporting env vars before the build):
+
+| Tier | Requires | Gets you |
+|---|---|---|
+| unsigned (default) | nothing | installs on the building machine; right-click → Open elsewhere |
+| signed | `EPISTEME_SIGN_APP_ID`, `EPISTEME_SIGN_INSTALLER_ID` (Developer ID certs — operator-only Apple enrollment) | Gatekeeper-recognized identity |
+| notarized | + `EPISTEME_NOTARY_PROFILE` (notarytool keychain profile) | clean first-run for any downloader |
+
+Local builds carry `com.apple.provenance` AppleDouble entries in the pkg
+payload — SIP-protected, re-stamped on write, unremovable by construction;
+harmless 11-byte metadata (release artifacts are CI-built via
+`release-assets.yml`). First check after enrolling: hardened runtime
+(`--options runtime`) vs the wry webview — if the window comes up blank, add a JIT entitlement (noted in
+`scripts/build_app_pkg.sh`). Release assets: `.github/workflows/release-assets.yml`
+attaches the wheel + pkg to a GitHub release (`workflow_dispatch` today;
+`release: published` activates once release-please gets the operator's PAT).
diff --git a/app/icon/episteme.icns b/app/icon/episteme.icns
new file mode 100644
index 0000000..db6ba16
Binary files /dev/null and b/app/icon/episteme.icns differ
diff --git a/app/icon/episteme.iconset/icon_128x128.png b/app/icon/episteme.iconset/icon_128x128.png
new file mode 100644
index 0000000..647b500
Binary files /dev/null and b/app/icon/episteme.iconset/icon_128x128.png differ
diff --git a/app/icon/episteme.iconset/icon_128x128@2x.png b/app/icon/episteme.iconset/icon_128x128@2x.png
new file mode 100644
index 0000000..da0d47e
Binary files /dev/null and b/app/icon/episteme.iconset/icon_128x128@2x.png differ
diff --git a/app/icon/episteme.iconset/icon_16x16.png b/app/icon/episteme.iconset/icon_16x16.png
new file mode 100644
index 0000000..fa900c5
Binary files /dev/null and b/app/icon/episteme.iconset/icon_16x16.png differ
diff --git a/app/icon/episteme.iconset/icon_16x16@2x.png b/app/icon/episteme.iconset/icon_16x16@2x.png
new file mode 100644
index 0000000..9b203aa
Binary files /dev/null and b/app/icon/episteme.iconset/icon_16x16@2x.png differ
diff --git a/app/icon/episteme.iconset/icon_256x256.png b/app/icon/episteme.iconset/icon_256x256.png
new file mode 100644
index 0000000..da0d47e
Binary files /dev/null and b/app/icon/episteme.iconset/icon_256x256.png differ
diff --git a/app/icon/episteme.iconset/icon_256x256@2x.png b/app/icon/episteme.iconset/icon_256x256@2x.png
new file mode 100644
index 0000000..86140f2
Binary files /dev/null and b/app/icon/episteme.iconset/icon_256x256@2x.png differ
diff --git a/app/icon/episteme.iconset/icon_32x32.png b/app/icon/episteme.iconset/icon_32x32.png
new file mode 100644
index 0000000..9b203aa
Binary files /dev/null and b/app/icon/episteme.iconset/icon_32x32.png differ
diff --git a/app/icon/episteme.iconset/icon_32x32@2x.png b/app/icon/episteme.iconset/icon_32x32@2x.png
new file mode 100644
index 0000000..d66fa48
Binary files /dev/null and b/app/icon/episteme.iconset/icon_32x32@2x.png differ
diff --git a/app/icon/episteme.iconset/icon_512x512.png b/app/icon/episteme.iconset/icon_512x512.png
new file mode 100644
index 0000000..86140f2
Binary files /dev/null and b/app/icon/episteme.iconset/icon_512x512.png differ
diff --git a/app/icon/episteme.iconset/icon_512x512@2x.png b/app/icon/episteme.iconset/icon_512x512@2x.png
new file mode 100644
index 0000000..4afb732
Binary files /dev/null and b/app/icon/episteme.iconset/icon_512x512@2x.png differ
diff --git a/app/icon/make_icns.swift b/app/icon/make_icns.swift
new file mode 100644
index 0000000..f81985a
--- /dev/null
+++ b/app/icon/make_icns.swift
@@ -0,0 +1,112 @@
+// Episteme.app icon generator (E179).
+//
+// The icns is a REPRODUCIBLE artifact: this script draws the mark with
+// CoreGraphics (no design binary without a source), writes the full iconset,
+// and runs iconutil. Regenerate with:
+//
+// swift app/icon/make_icns.swift app/icon
+//
+// Mark: the Gate — two accent brackets facing each other with the operator
+// (a filled dot) between them, on the console's dark panel color. Geometry
+// is proportional to size, with stroke widths tuned so the gate survives
+// 16 px.
+
+import AppKit
+import CoreGraphics
+import Foundation
+
+let bg = CGColor(red: 0x0B / 255.0, green: 0x0E / 255.0, blue: 0x14 / 255.0, alpha: 1)
+let edge = CGColor(red: 0x1E / 255.0, green: 0x25 / 255.0, blue: 0x30 / 255.0, alpha: 1)
+let accent = CGColor(red: 0x5E / 255.0, green: 0xC2 / 255.0, blue: 0xA6 / 255.0, alpha: 1)
+
+func drawIcon(size: Int) -> CGImage {
+ let s = CGFloat(size)
+ let ctx = CGContext(
+ data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )!
+
+ // Background: rounded square (macOS icon-grid inset), subtle edge.
+ let inset = s * 0.06
+ let rect = CGRect(x: inset, y: inset, width: s - 2 * inset, height: s - 2 * inset)
+ let radius = rect.width * 0.225
+ let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
+ ctx.addPath(path)
+ ctx.setFillColor(bg)
+ ctx.fillPath()
+ if size >= 64 {
+ ctx.addPath(path)
+ ctx.setStrokeColor(edge)
+ ctx.setLineWidth(max(1, s * 0.008))
+ ctx.strokePath()
+ }
+
+ // The Gate: two brackets [ ] + the operator dot between them.
+ let stroke = max(1.5, s * 0.075)
+ let armLen = s * 0.14
+ let bracketH = s * 0.46
+ let leftX = s * 0.30
+ let rightX = s * 0.70
+ let midY = s * 0.5
+
+ ctx.setStrokeColor(accent)
+ ctx.setLineWidth(stroke)
+ ctx.setLineCap(.round)
+ ctx.setLineJoin(.round)
+
+ // Left bracket [
+ ctx.beginPath()
+ ctx.move(to: CGPoint(x: leftX + armLen, y: midY + bracketH / 2))
+ ctx.addLine(to: CGPoint(x: leftX, y: midY + bracketH / 2))
+ ctx.addLine(to: CGPoint(x: leftX, y: midY - bracketH / 2))
+ ctx.addLine(to: CGPoint(x: leftX + armLen, y: midY - bracketH / 2))
+ ctx.strokePath()
+
+ // Right bracket ]
+ ctx.beginPath()
+ ctx.move(to: CGPoint(x: rightX - armLen, y: midY + bracketH / 2))
+ ctx.addLine(to: CGPoint(x: rightX, y: midY + bracketH / 2))
+ ctx.addLine(to: CGPoint(x: rightX, y: midY - bracketH / 2))
+ ctx.addLine(to: CGPoint(x: rightX - armLen, y: midY - bracketH / 2))
+ ctx.strokePath()
+
+ // The operator: a filled dot at the gate's center.
+ let dotR = s * 0.065
+ ctx.setFillColor(accent)
+ ctx.fillEllipse(in: CGRect(x: s / 2 - dotR, y: midY - dotR, width: dotR * 2, height: dotR * 2))
+
+ return ctx.makeImage()!
+}
+
+func writePNG(_ image: CGImage, to url: URL) {
+ let rep = NSBitmapImageRep(cgImage: image)
+ let data = rep.representation(using: .png, properties: [:])!
+ try! data.write(to: url)
+}
+
+let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "app/icon"
+let iconset = URL(fileURLWithPath: outDir).appendingPathComponent("episteme.iconset")
+try? FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
+
+// (filename base size, pixel size) pairs iconutil expects.
+let entries: [(String, Int)] = [
+ ("icon_16x16", 16), ("icon_16x16@2x", 32),
+ ("icon_32x32", 32), ("icon_32x32@2x", 64),
+ ("icon_128x128", 128), ("icon_128x128@2x", 256),
+ ("icon_256x256", 256), ("icon_256x256@2x", 512),
+ ("icon_512x512", 512), ("icon_512x512@2x", 1024),
+]
+for (name, px) in entries {
+ writePNG(drawIcon(size: px), to: iconset.appendingPathComponent("\(name).png"))
+}
+
+let task = Process()
+task.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
+task.arguments = [
+ "-c", "icns", iconset.path,
+ "-o", URL(fileURLWithPath: outDir).appendingPathComponent("episteme.icns").path,
+]
+try! task.run()
+task.waitUntilExit()
+print("wrote \(outDir)/episteme.icns (exit \(task.terminationStatus))")
diff --git a/scripts/build_app_pkg.sh b/scripts/build_app_pkg.sh
index 1a708a0..1c457cf 100755
--- a/scripts/build_app_pkg.sh
+++ b/scripts/build_app_pkg.sh
@@ -22,8 +22,11 @@ echo "==> cargo build --release (app shell v$VERSION)"
echo "==> assembling $APP"
rm -rf "$APP"
-mkdir -p "$APP/Contents/MacOS"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$APP_DIR/target/release/episteme-app" "$APP/Contents/MacOS/episteme-app"
+# Icon (E179): committed reproducible artifact; regenerate with
+# swift app/icon/make_icns.swift app/icon
+cp "$APP_DIR/icon/episteme.icns" "$APP/Contents/Resources/episteme.icns"
cat > "$APP/Contents/Info.plist" << PLIST
"$APP/Contents/Info.plist" << PLIST
CFBundleVersion$VERSION
CFBundleShortVersionString$VERSION
CFBundleExecutableepisteme-app
+ CFBundleIconFileepisteme
CFBundlePackageTypeAPPL
LSMinimumSystemVersion12.0
NSHighResolutionCapable
@@ -43,14 +47,63 @@ cat > "$APP/Contents/Info.plist" << PLIST
PLIST
-echo "==> pkgbuild (unsigned)"
-pkgbuild \
- --component "$APP" \
+# AppleDouble hygiene (E176 review → E179 measured reality): xattr -cr +
+# ditto --noextattr remove the REMOVABLE class (quarantine, FinderInfo,
+# resource forks). com.apple.provenance is SIP-protected AND re-stamped by
+# the OS on every local write, so local builds ALWAYS carry its 11-byte
+# AppleDouble entries in the payload — unavoidable by construction, harmless
+# metadata. Release artifacts come from CI (release-assets.yml) where this
+# does not gate anything; do not chase the residue further.
+xattr -cr "$APP" 2>/dev/null || true
+STAGED="$DIST/.staging/Episteme.app"
+rm -rf "$DIST/.staging"
+mkdir -p "$DIST/.staging"
+ditto --noextattr --norsrc --noacl "$APP" "$STAGED"
+
+# Signing tiers (E179): credential-ready, inert without the operator's cert.
+# EPISTEME_SIGN_APP_ID "Developer ID Application: Name (TEAMID)"
+# EPISTEME_SIGN_INSTALLER_ID "Developer ID Installer: Name (TEAMID)"
+# EPISTEME_NOTARY_PROFILE notarytool keychain profile name
+# FIRST CHECK once a real cert exists: hardened runtime vs the wry webview —
+# if the window comes up blank under --options runtime, add an entitlements
+# plist (com.apple.security.cs.allow-jit) here.
+TIER="unsigned (personal use)"
+if [ -n "${EPISTEME_SIGN_APP_ID:-}" ]; then
+ echo "==> codesign (hardened runtime, on the STAGED copy — signing must
+ follow attribute-stripping, never precede it)"
+ codesign --force --deep --options runtime --timestamp \
+ --sign "$EPISTEME_SIGN_APP_ID" "$STAGED"
+ TIER="signed"
+fi
+
+echo "==> pkgbuild"
+RAW_PKG="$DIST/Episteme-unsigned.pkg"
+COPYFILE_DISABLE=1 pkgbuild \
+ --component "$STAGED" \
--install-location /Applications \
--identifier com.episteme.app \
--version "$VERSION" \
- "$DIST/Episteme.pkg"
+ "$RAW_PKG"
+
+if [ -n "${EPISTEME_SIGN_INSTALLER_ID:-}" ]; then
+ echo "==> productsign"
+ productsign --sign "$EPISTEME_SIGN_INSTALLER_ID" "$RAW_PKG" "$DIST/Episteme.pkg"
+ rm -f "$RAW_PKG"
+ TIER="signed installer"
+else
+ mv "$RAW_PKG" "$DIST/Episteme.pkg"
+fi
+
+if [ -n "${EPISTEME_NOTARY_PROFILE:-}" ]; then
+ echo "==> notarytool submit (waits for Apple)"
+ xcrun notarytool submit "$DIST/Episteme.pkg" \
+ --keychain-profile "$EPISTEME_NOTARY_PROFILE" --wait
+ xcrun stapler staple "$DIST/Episteme.pkg"
+ TIER="notarized"
+fi
-echo "done:"
+echo "done: tier=$TIER"
ls -lh "$DIST/Episteme.pkg"
-echo "install: open dist/Episteme.pkg (unsigned — right-click → Open on first run)"
+if [ "$TIER" = "unsigned (personal use)" ]; then
+ echo "install: open dist/Episteme.pkg (unsigned — right-click → Open on first run)"
+fi