diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md
new file mode 100644
index 0000000000..ae781be604
--- /dev/null
+++ b/apps/gittensory-miner-extension/README.md
@@ -0,0 +1,13 @@
+# Gittensory Miner Extension
+
+Contributor-facing browser extension scaffold for GitHub **issue** pages. It is intentionally separate from
+[`apps/gittensory-extension/`](../gittensory-extension/) (the **Maintainer Overlay**), which injects private PR/issue
+context for maintainers.
+
+This package is Phase 6 scaffolding only:
+
+- Manifest V3 with issue-page `content_scripts`
+- `background.js` service worker + `content.js` message-passing shell
+- Options page for local watched-repo configuration
+
+The read-only issue-page opportunity badge is implemented in a follow-up issue after this scaffold lands.
diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js
new file mode 100644
index 0000000000..328458d75f
--- /dev/null
+++ b/apps/gittensory-miner-extension/background.js
@@ -0,0 +1,48 @@
+const PING_MESSAGE = "gittensory-miner:ping";
+const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context";
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (!message || typeof message.type !== "string") return false;
+ if (message.type === PING_MESSAGE) {
+ sendResponse({ ok: true, payload: { ready: true } });
+ return false;
+ }
+ if (message.type === ISSUE_CONTEXT_MESSAGE) {
+ const task = loadIssueContextShell(message);
+ void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) =>
+ sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }),
+ );
+ return true;
+ }
+ return false;
+});
+
+async function loadIssueContextShell(message) {
+ const settings = await loadMinerExtensionSettings();
+ const repoFullName = `${message.owner}/${message.repo}`;
+ const watched = settings.watchedRepos.includes(repoFullName);
+ return {
+ watched,
+ issueNumber: message.issueNumber,
+ repoFullName,
+ badge: null,
+ status: watched ? "shell-ready" : "repo-not-watched",
+ };
+}
+
+async function loadMinerExtensionSettings() {
+ const stored = await chrome.storage.sync.get({ watchedRepos: [] });
+ const watchedRepos = Array.isArray(stored.watchedRepos)
+ ? stored.watchedRepos.map((value) => String(value).trim()).filter(Boolean)
+ : [];
+ return { watchedRepos };
+}
+
+if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
+ globalThis.__gittensoryMinerBackgroundInternals = {
+ PING_MESSAGE,
+ ISSUE_CONTEXT_MESSAGE,
+ loadIssueContextShell,
+ loadMinerExtensionSettings,
+ };
+}
diff --git a/apps/gittensory-miner-extension/content.js b/apps/gittensory-miner-extension/content.js
new file mode 100644
index 0000000000..80db3ffa50
--- /dev/null
+++ b/apps/gittensory-miner-extension/content.js
@@ -0,0 +1,56 @@
+const target = matchGitHubIssueTarget(location.pathname);
+
+if (target?.kind === "issue") {
+ mountIssueShell(target);
+}
+
+function matchGitHubIssueTarget(pathname) {
+ const match = String(pathname ?? "").match(/^\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\/|$)/);
+ if (!match) return null;
+ const [, owner, repo, number] = match;
+ return { kind: "issue", owner, repo, issueNumber: Number(number) };
+}
+
+function mountIssueShell(target) {
+ if (document.querySelector("[data-gittensory-miner-issue-shell]")) return;
+ const container = document.createElement("aside");
+ container.className = "gittensory-miner-issue-shell";
+ container.dataset.gittensoryMinerIssueShell = "true";
+ container.hidden = true;
+ document.body.appendChild(container);
+ void loadIssueShell(container, target);
+}
+
+async function loadIssueShell(container, target) {
+ const response = await chrome.runtime.sendMessage({
+ type: "gittensory-miner:issue-context",
+ owner: target.owner,
+ repo: target.repo,
+ issueNumber: target.issueNumber,
+ });
+ if (!response?.ok) {
+ container.hidden = true;
+ return;
+ }
+ renderIssueShell(container, response.payload);
+}
+
+function renderIssueShell(container, payload) {
+ if (!payload?.watched) {
+ container.hidden = true;
+ return;
+ }
+ container.hidden = false;
+ container.textContent = "";
+ const label = document.createElement("span");
+ label.className = "gittensory-miner-issue-shell__label";
+ label.textContent = "Gittensory miner opportunity shell";
+ container.appendChild(label);
+}
+
+if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
+ globalThis.__gittensoryMinerContentInternals = {
+ matchGitHubIssueTarget,
+ renderIssueShell,
+ };
+}
diff --git a/apps/gittensory-miner-extension/manifest.json b/apps/gittensory-miner-extension/manifest.json
new file mode 100644
index 0000000000..5bb4bada21
--- /dev/null
+++ b/apps/gittensory-miner-extension/manifest.json
@@ -0,0 +1,23 @@
+{
+ "manifest_version": 3,
+ "name": "Gittensory Miner Opportunity",
+ "description": "Contributor-facing GitHub issue opportunity signals from a locally configured miner plane.",
+ "version": "0.1.0",
+ "permissions": ["storage"],
+ "host_permissions": ["https://github.com/*"],
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+ "content_scripts": [
+ {
+ "matches": ["https://github.com/*/*/issues/*"],
+ "js": ["content.js"],
+ "run_at": "document_idle"
+ }
+ ],
+ "options_page": "options.html",
+ "action": {
+ "default_title": "Gittensory Miner"
+ }
+}
diff --git a/apps/gittensory-miner-extension/options.html b/apps/gittensory-miner-extension/options.html
new file mode 100644
index 0000000000..d575743f7b
--- /dev/null
+++ b/apps/gittensory-miner-extension/options.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ Gittensory Miner Extension
+
+
+
+
+ Gittensory miner extension
+
+ Configure which repositories this contributor-facing extension should watch on GitHub issue pages. This is a
+ separate product surface from the maintainer overlay at apps/gittensory-extension/.
+
+
+
+
+
+
+
diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js
new file mode 100644
index 0000000000..9d929b2455
--- /dev/null
+++ b/apps/gittensory-miner-extension/options.js
@@ -0,0 +1,47 @@
+function parseWatchedRepos(text) {
+ return String(text ?? "")
+ .split(/\r?\n|,/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
+ globalThis.__gittensoryMinerOptionsInternals = {
+ parseWatchedRepos,
+ };
+}
+
+const form = document.querySelector("#settings");
+const status = document.querySelector("#status");
+const watchedRepos = document.querySelector("#watchedRepos");
+
+if (!form || !status || !watchedRepos) {
+ // options.html is not mounted (unit-test harness or partial load).
+} else {
+void refreshSettings();
+
+form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ try {
+ const repos = parseWatchedRepos(watchedRepos.value);
+ await chrome.storage.sync.set({ watchedRepos: repos });
+ await refreshSettings();
+ showStatus(repos.length > 0 ? `Watching ${repos.length} repository(ies).` : "Cleared watched repositories.");
+ } catch (error) {
+ showStatus(error instanceof Error ? error.message : String(error));
+ }
+});
+}
+
+async function refreshSettings() {
+ const stored = await chrome.storage.sync.get({ watchedRepos: [] });
+ const repos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos : [];
+ watchedRepos.value = repos.join("\n");
+}
+
+function showStatus(message) {
+ status.textContent = message;
+ window.setTimeout(() => {
+ status.textContent = "";
+ }, 2600);
+}
diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts
new file mode 100644
index 0000000000..d552c8b126
--- /dev/null
+++ b/test/unit/miner-extension-content.test.ts
@@ -0,0 +1,175 @@
+import { readFileSync } from "node:fs";
+import { Script, createContext } from "node:vm";
+import { describe, expect, it } from "vitest";
+
+const contentScript = readFileSync("apps/gittensory-miner-extension/content.js", "utf8");
+const backgroundScript = readFileSync("apps/gittensory-miner-extension/background.js", "utf8");
+const optionsScript = readFileSync("apps/gittensory-miner-extension/options.js", "utf8");
+const manifest = JSON.parse(readFileSync("apps/gittensory-miner-extension/manifest.json", "utf8"));
+
+describe("miner extension scaffold", () => {
+ it("ships a Manifest V3 issue-page-only content script surface", () => {
+ expect(manifest.manifest_version).toBe(3);
+ expect(manifest.name).toContain("Miner");
+ expect(manifest.content_scripts).toHaveLength(1);
+ expect(manifest.content_scripts[0].matches).toEqual(["https://github.com/*/*/issues/*"]);
+ expect(manifest.background.service_worker).toBe("background.js");
+ expect(manifest.options_page).toBe("options.html");
+ });
+
+ it("detects GitHub issue routes without matching pull requests", () => {
+ const internals = loadContentInternals();
+
+ expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues/145")).toEqual({
+ kind: "issue",
+ owner: "JSONbored",
+ repo: "gittensory",
+ issueNumber: 145,
+ });
+ expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/pull/146")).toBeNull();
+ expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues")).toBeNull();
+ });
+
+ it("renders the watched-repo shell placeholder without a badge payload", () => {
+ const internals = loadContentInternals();
+ const container = createMockContainer();
+
+ internals.renderIssueShell(container, {
+ watched: true,
+ issueNumber: 145,
+ repoFullName: "JSONbored/gittensory",
+ badge: null,
+ status: "shell-ready",
+ });
+
+ expect(container.hidden).toBe(false);
+ expect(container.textContent).toContain("opportunity shell");
+ });
+
+ it("keeps the shell hidden for unwatched repositories", () => {
+ const internals = loadContentInternals();
+ const container = createMockContainer();
+
+ internals.renderIssueShell(container, {
+ watched: false,
+ issueNumber: 145,
+ repoFullName: "JSONbored/gittensory",
+ badge: null,
+ status: "repo-not-watched",
+ });
+
+ expect(container.hidden).toBe(true);
+ });
+
+ it("returns shell-ready issue context for watched repositories", async () => {
+ const internals = loadBackgroundInternals({
+ watchedRepos: ["JSONbored/gittensory"],
+ });
+
+ const payload = await internals.loadIssueContextShell({
+ owner: "JSONbored",
+ repo: "gittensory",
+ issueNumber: 145,
+ });
+
+ expect(payload).toEqual({
+ watched: true,
+ issueNumber: 145,
+ repoFullName: "JSONbored/gittensory",
+ badge: null,
+ status: "shell-ready",
+ });
+ });
+
+ it("parses watched repositories from newline or comma separated options input", () => {
+ const internals = loadOptionsInternals();
+ expect(internals.parseWatchedRepos("JSONbored/gittensory\nowner/repo")).toEqual([
+ "JSONbored/gittensory",
+ "owner/repo",
+ ]);
+ expect(internals.parseWatchedRepos("JSONbored/gittensory, owner/repo")).toEqual([
+ "JSONbored/gittensory",
+ "owner/repo",
+ ]);
+ });
+});
+
+function createMockContainer() {
+ const container = {
+ hidden: false,
+ textContent: "",
+ dataset: {} as Record,
+ appendChild(node: { textContent?: string }) {
+ if (node.textContent) {
+ container.textContent += node.textContent;
+ }
+ },
+ };
+ return container as {
+ hidden: boolean;
+ textContent: string;
+ dataset: Record;
+ appendChild: (node: { textContent?: string }) => void;
+ };
+}
+
+function loadContentInternals() {
+ const context: Record = {
+ __GITTENSORY_MINER_EXTENSION_TEST__: true,
+ location: { pathname: "/JSONbored/gittensory/pull/146" },
+ document: {
+ querySelector: () => null,
+ createElement: () => createMockContainer(),
+ body: { appendChild: () => {} },
+ },
+ chrome: { runtime: { sendMessage: async () => ({ ok: true, payload: { watched: true } }) } },
+ };
+ context.globalThis = context;
+ const vmContext = createContext(context);
+ new Script(contentScript).runInContext(vmContext);
+ return vmContext.__gittensoryMinerContentInternals as {
+ matchGitHubIssueTarget: (
+ pathname: string,
+ ) => { kind: "issue"; owner: string; repo: string; issueNumber: number } | null;
+ renderIssueShell: (container: { hidden: boolean; textContent: string }, payload: unknown) => void;
+ };
+}
+
+function loadBackgroundInternals({ watchedRepos = [] as string[] } = {}) {
+ const context: Record = {
+ __GITTENSORY_MINER_EXTENSION_TEST__: true,
+ chrome: {
+ storage: { sync: { get: async () => ({ watchedRepos }) } },
+ runtime: { onMessage: { addListener: () => {} } },
+ },
+ };
+ context.globalThis = context;
+ const vmContext = createContext(context);
+ new Script(backgroundScript).runInContext(vmContext);
+ return vmContext.__gittensoryMinerBackgroundInternals as {
+ loadIssueContextShell: (message: {
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ }) => Promise>;
+ };
+}
+
+function loadOptionsInternals() {
+ const context: Record = {
+ __GITTENSORY_MINER_EXTENSION_TEST__: true,
+ document: {
+ querySelector: () => null,
+ },
+ chrome: {
+ storage: { sync: { get: async () => ({ watchedRepos: [] }), set: async () => {} } },
+ },
+ setTimeout: () => 0,
+ };
+ context.globalThis = context;
+ const vmContext = createContext(context);
+ new Script(optionsScript).runInContext(vmContext);
+ return vmContext.__gittensoryMinerOptionsInternals as {
+ parseWatchedRepos: (text: string) => string[];
+ };
+}