diff --git a/docs/fork-features.md b/docs/fork-features.md
new file mode 100644
index 000000000000..9c2e2e8dff55
--- /dev/null
+++ b/docs/fork-features.md
@@ -0,0 +1,289 @@
+# Fork feature map
+
+This is the canonical feature inventory consumed by `scripts/fork-features.mjs`.
+Each JSON block is one feature. Keep stable ids, a one-line purpose, Issue/PR
+links, exact repository-relative paths and literal, case-insensitive watch keywords.
+Empty file arrays mean no new files or no independently owned upstream edits.
+
+`upstreamFiles` assigns every allowlisted path to exactly one primary feature.
+`sharedFiles` records other features using that path; all three file arrays take
+part in overlap detection. Primary ownership does not imply exclusive behavior.
+Some historical allowlist entries (the threads toolkit) are actually fork-new
+files; they remain allowlisted and have one owner for compatibility.
+
+Update this map with each fork feature or deletion. The report is a heuristic:
+renamed capabilities or different vocabulary still need human review. Keywords
+flag possible overlap, not proof of duplication. See [the routine](fork.md#routine)
+for per-match decisions and the absorption PR record.
+
+## Project Direction
+
+```json
+{
+ "id": "direction",
+ "purpose": "Keep the fork mission and completion criteria explicit.",
+ "issues": ["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/toolboxmd/t3code/issues/5"],
+ "prs": ["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/toolboxmd/t3code/pull/5"],
+ "newFiles": ["MISSION.md", "OBJECTIVE.md", "VISION.md"],
+ "upstreamFiles": [],
+ "sharedFiles": [],
+ "keywords": ["project direction", "VISION.md", "MISSION.md", "OBJECTIVE.md"]
+}
+```
+
+## Product glossary
+
+```json
+{
+ "id": "glossary",
+ "purpose": "Keep Chromeria and its product family terminology consistent.",
+ "issues": ["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/toolboxmd/t3code/issues/11"],
+ "prs": ["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/toolboxmd/t3code/pull/11"],
+ "newFiles": ["GLOSSARY.md"],
+ "upstreamFiles": [],
+ "sharedFiles": [],
+ "keywords": ["Chromeria", "Luxin", "Drafter", "Prism"]
+}
+```
+
+## Chromeria branding
+
+```json
+{
+ "id": "branding",
+ "purpose": "Give desktop and web the Chromeria name, icons and independent desktop identity.",
+ "issues": [
+ "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/toolboxmd/t3code/issues/12",
+ "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/toolboxmd/t3code/issues/13",
+ "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/toolboxmd/t3code/issues/14"
+ ],
+ "prs": [
+ "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/toolboxmd/t3code/pull/12",
+ "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/toolboxmd/t3code/pull/13",
+ "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/toolboxmd/t3code/pull/14"
+ ],
+ "newFiles": [
+ "assets/chromeria/chromeria-icon-1024.png",
+ "assets/chromeria/chromeria-web-apple-touch-180.png",
+ "assets/chromeria/chromeria-web-favicon-16x16.png",
+ "assets/chromeria/chromeria-web-favicon-32x32.png",
+ "assets/chromeria/chromeria-web-favicon.ico",
+ "assets/chromeria/chromeria-windows.ico",
+ "apps/web/public/chromeria-mark.png"
+ ],
+ "upstreamFiles": [
+ "apps/desktop/package.json",
+ "apps/desktop/src/app/DesktopAppIdentity.test.ts",
+ "apps/desktop/src/app/DesktopEnvironment.ts",
+ "apps/desktop/src/app/DesktopPreReadyPlatform.test.ts",
+ "scripts/build-desktop-artifact.ts",
+ "scripts/build-desktop-artifact.test.ts",
+ "scripts/lib/brand-assets.ts",
+ "scripts/lib/brand-assets.test.ts",
+ "apps/web/index.html",
+ "apps/web/src/bootstrap.test.ts",
+ "apps/web/src/branding.test.ts",
+ "apps/web/src/branding.ts",
+ "apps/web/src/bundledDev.test.ts",
+ "apps/web/src/components/T3Wordmark.tsx",
+ "apps/web/src/components/chat/MessagesTimeline.tsx",
+ "apps/web/src/components/onboarding/WelcomeWizard.tsx",
+ "apps/web/src/components/settings/IntegrationsSettings.tsx",
+ "apps/web/src/components/settings/ThemePreviewCircles.tsx",
+ "apps/web/src/components/sidebar/SidebarChrome.tsx",
+ "apps/web/src/lib/bootError.ts"
+ ],
+ "sharedFiles": [],
+ "keywords": [
+ "branding",
+ "productName",
+ "appId",
+ "wordmark",
+ "favicon",
+ "Chromeria",
+ "auto-update"
+ ]
+}
+```
+
+## Child threads
+
+```json
+{
+ "id": "child-threads",
+ "purpose": "Spawn child threads, hide them from sidebars and open them from their parent.",
+ "issues": ["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/toolboxmd/t3code/issues/8"],
+ "prs": ["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/toolboxmd/t3code/pull/10"],
+ "newFiles": [
+ "apps/server/src/mcp/toolkits/threads/childThreads.test.ts",
+ "apps/server/src/mcp/toolkits/threads/handlers.ts",
+ "apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts",
+ "apps/server/src/mcp/toolkits/threads/subagentThreadId.ts",
+ "apps/server/src/mcp/toolkits/threads/tools.ts",
+ "apps/web/src/components/AgentThreadLink.tsx",
+ "apps/web/src/components/subagentThreads.test.ts",
+ "apps/web/src/components/subagentThreads.ts"
+ ],
+ "upstreamFiles": [
+ "apps/server/src/entrypoint.test.ts",
+ "apps/server/src/mcp/McpHttpServer.ts",
+ "apps/web/src/components/LegacySidebar.tsx",
+ "apps/web/src/components/Sidebar.tsx"
+ ],
+ "sharedFiles": ["scripts/build-desktop-artifact.ts", "apps/web/src/components/AgentsPanel.tsx"],
+ "keywords": ["parentThreadId", "child thread", "subagent", "spawn_thread", "sidebar"]
+}
+```
+
+## Fork maintenance and CI
+
+```json
+{
+ "id": "fork-maintenance",
+ "purpose": "Keep the fork stack small, checked and rebasable on GitHub-hosted CI.",
+ "issues": [
+ "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/toolboxmd/t3code/issues/6",
+ "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/toolboxmd/t3code/issues/20"
+ ],
+ "prs": [
+ "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/toolboxmd/t3code/pull/9",
+ "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/toolboxmd/t3code/pull/24"
+ ],
+ "newFiles": [
+ ".github/workflows/fork.yml",
+ "docs/fork.md",
+ "scripts/fork-check.sh",
+ "scripts/fork-maintenance.test.ts",
+ "scripts/fork-rebase.sh",
+ "scripts/fork-upstream-edits.txt",
+ "docs/fork-features.md",
+ "scripts/fork-features.mjs"
+ ],
+ "upstreamFiles": [
+ ".github/workflows/ci.yml",
+ ".github/workflows/mobile-fingerprint-check.yml",
+ "knip.jsonc"
+ ],
+ "sharedFiles": ["scripts/build-desktop-artifact.ts", "apps/server/src/entrypoint.test.ts"],
+ "keywords": ["fork", "rebase", "upstream", "blacksmith", "ELECTRON_RUN_AS_NODE", "TMPDIR"]
+}
+```
+
+## Thread scope
+
+```json
+{
+ "id": "thread-scope",
+ "purpose": "Allow explicit same-project supervision while retaining child-only defaults.",
+ "issues": ["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/toolboxmd/t3code/issues/15"],
+ "prs": ["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/toolboxmd/t3code/pull/16"],
+ "newFiles": [],
+ "upstreamFiles": [
+ "apps/server/src/mcp/toolkits/threads/childThreads.test.ts",
+ "apps/server/src/mcp/toolkits/threads/handlers.ts",
+ "apps/server/src/mcp/toolkits/threads/tools.ts"
+ ],
+ "sharedFiles": [],
+ "keywords": ["thread scope", "scope", "projectId", "list_threads", "read_thread", "send_message"]
+}
+```
+
+## Agents panel
+
+```json
+{
+ "id": "agents-panel",
+ "purpose": "Separate Prism and direct spawns, nest child threads and navigate parent and siblings.",
+ "issues": ["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/toolboxmd/t3code/issues/17"],
+ "prs": ["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/toolboxmd/t3code/pull/18"],
+ "newFiles": [
+ "apps/web/src/components/AgentThreadTree.logic.test.ts",
+ "apps/web/src/components/AgentThreadTree.logic.ts",
+ "apps/web/src/components/AgentThreadTree.tsx",
+ "apps/web/src/components/chat/ThreadParentCrumbs.tsx"
+ ],
+ "upstreamFiles": [
+ "apps/web/src/components/AgentsPanel.tsx",
+ "apps/web/src/components/chat/ChatHeader.tsx"
+ ],
+ "sharedFiles": [],
+ "keywords": [
+ "AgentsPanel",
+ "Prism Spawns",
+ "Direct Spawns",
+ "child tree",
+ "breadcrumb",
+ "parentThreadId",
+ "sidebar section"
+ ]
+}
+```
+
+## Prism toolkit and role kits
+
+```json
+{
+ "id": "prism-toolkit",
+ "purpose": "Expose provider capacity and assign scoped thread tools through Prism roles.",
+ "issues": ["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/toolboxmd/t3code/issues/19"],
+ "prs": ["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/toolboxmd/t3code/pull/22"],
+ "newFiles": [
+ "apps/server/src/mcp/toolkits/prism/handlers.test.ts",
+ "apps/server/src/mcp/toolkits/prism/handlers.ts",
+ "apps/server/src/mcp/toolkits/prism/tools.ts",
+ "apps/server/src/mcp/toolkits/threads/roles.test.ts",
+ "apps/server/src/mcp/toolkits/threads/roles.ts",
+ "apps/server/src/prism/snapshotRoute.test.ts",
+ "apps/server/src/prism/snapshotRoute.ts",
+ "packages/contracts/src/prism.test.ts",
+ "packages/contracts/src/prism.ts",
+ "packages/contracts/src/prismSnapshot.ts"
+ ],
+ "upstreamFiles": [
+ "apps/server/src/provider/Drivers/OpenCodeDriver.ts",
+ "packages/contracts/src/index.ts",
+ "packages/contracts/src/settings.ts"
+ ],
+ "sharedFiles": [
+ "apps/server/src/mcp/McpHttpServer.ts",
+ "apps/server/src/mcp/toolkits/threads/handlers.ts",
+ "apps/server/src/mcp/toolkits/threads/tools.ts"
+ ],
+ "keywords": [
+ "prism",
+ "role kit",
+ "prismRoles",
+ "spawn_thread",
+ "provider snapshot",
+ "capacity",
+ "usage limit",
+ "resume"
+ ]
+}
+```
+
+## Prism settings page
+
+```json
+{
+ "id": "prism-settings",
+ "purpose": "Configure role preferences and show provider usage and capacity in Settings.",
+ "issues": ["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/toolboxmd/t3code/issues/21"],
+ "prs": ["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/toolboxmd/t3code/pull/23"],
+ "newFiles": [
+ "apps/web/src/components/settings/PrismSettings.logic.test.ts",
+ "apps/web/src/components/settings/PrismSettings.logic.ts",
+ "apps/web/src/components/settings/PrismSettings.state.test.ts",
+ "apps/web/src/components/settings/PrismSettings.state.ts",
+ "apps/web/src/components/settings/PrismSettings.tsx",
+ "apps/web/src/routes/settings.prism.tsx"
+ ],
+ "upstreamFiles": [
+ "apps/web/src/components/settings/SettingsSidebarNav.tsx",
+ "apps/web/src/components/settings/settingsSearch.ts",
+ "apps/web/src/routeTree.gen.ts"
+ ],
+ "sharedFiles": [],
+ "keywords": ["PrismSettings", "prismRoles", "role preferences", "capacity", "usage", "settings"]
+}
+```
diff --git a/docs/fork.md b/docs/fork.md
index 1d4da4011e88..23cb47c8c6c3 100644
--- a/docs/fork.md
+++ b/docs/fork.md
@@ -20,86 +20,26 @@ patch set further.
and then lands with `--force-with-lease` (see Routine). That push is a
human-approved step.
- **New files first.** Features go in new files and new packages. Edits to
- upstream-owned files stay minimal and every edited file is listed under
- "Upstream edits" below and in the machine-checked allowlist
- `scripts/fork-upstream-edits.txt` (one path per line).
+ upstream-owned files stay minimal and every edited file has a primary owner
+ in [the feature map](fork-features.md) and an entry in the machine-checked
+ allowlist `scripts/fork-upstream-edits.txt` (one path per line).
- **Remotes.** `origin` is `toolboxmd/t3code`; `upstream` is
`pingdotgg/t3code`. Never push to `upstream`, never send fork commits there
(offering extension points upstream is a separate human decision, out of
scope for routine maintenance).
-## Fork-only content
-
-New files owned by the fork (no upstream counterpart, always allowed):
-
-- `VISION.md`, `MISSION.md`, `OBJECTIVE.md`, `GLOSSARY.md`: Project Direction
- and project language.
-- `assets/chromeria/`, `apps/web/public/chromeria-mark.png`: Chromeria icons.
-- `docs/fork.md`: this document.
-- `scripts/fork-rebase.sh`: the rebase routine.
-- `scripts/fork-check.sh`: the stack-model check run by CI.
-- `scripts/fork-maintenance.test.ts`: tests driving both scripts against
- throwaway git fixtures (runs with upstream's own test suite).
-- `scripts/fork-upstream-edits.txt`: allowlist of upstream files the fork
- may modify.
-- `.github/workflows/fork.yml`: fork CI (the stack check).
-
-## Upstream edits
-
-Upstream-owned files modified by the fork. `scripts/fork-check.sh` fails on
-any modified upstream file missing from `scripts/fork-upstream-edits.txt`.
-
-- Chromeria branding (#12, #13): `apps/desktop/package.json` (product
- name), `apps/desktop/src/app/DesktopEnvironment.ts` (names, userData
- directory), `scripts/build-desktop-artifact.ts` (app id, artifact name, no
- update feed, icons), `scripts/lib/brand-assets.ts` (icon paths), and the
- expectations in `apps/desktop/src/app/DesktopAppIdentity.test.ts`,
- `apps/desktop/src/app/DesktopPreReadyPlatform.test.ts`,
- `scripts/build-desktop-artifact.test.ts` and
- `scripts/lib/brand-assets.test.ts`.
-- Chromeria name in the web UI (#14): `apps/web/index.html`,
- `apps/web/src/branding.ts`, `apps/web/src/lib/bootError.ts`, and the
- branding fixture in `apps/web/src/bundledDev.test.ts`, plus the
- components `T3Wordmark.tsx`, `chat/MessagesTimeline.tsx`,
- `onboarding/WelcomeWizard.tsx`, `settings/IntegrationsSettings.tsx`,
- `settings/ThemePreviewCircles.tsx` and `sidebar/SidebarChrome.tsx` under
- `apps/web/src/components/`, with expectations in
- `apps/web/src/bootstrap.test.ts` and `apps/web/src/branding.test.ts`.
-- Child threads and project-scoped supervision (#8, #10, #15):
- `apps/server/src/mcp/McpHttpServer.ts` registers the threads MCP toolkit;
- `apps/server/src/mcp/toolkits/threads/childThreads.test.ts`, `handlers.ts`
- and `tools.ts` implement and test child and same-project listing, reading and
- messaging; `apps/web/src/components/AgentsPanel.tsx` links agents to their
- child threads; `apps/web/src/components/Sidebar.tsx` and
- `apps/web/src/components/LegacySidebar.tsx` hide child threads from the
- sidebar.
-- Agents panel sections, child tree and breadcrumb (#17):
- `apps/web/src/components/AgentsPanel.tsx` splits Prism spawns from direct
- spawns and nests child threads under their row;
- `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and
- sibling menu for a child thread. The logic lives in the fork-owned
- `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`.
-- Prism settings (#21): `apps/web/src/components/settings/SettingsSidebarNav.tsx`
- and `settingsSearch.ts` register the page below Providers and in settings search;
- `apps/web/src/routeTree.gen.ts` is regenerated for the fork-owned route.
- The page and its preference logic live in new `PrismSettings` files.
-- Prism toolkit and role kits (#19): `packages/contracts/src/settings.ts`
- adds the `prismRoles` server setting (project-scoped, with its patch) and
- `packages/contracts/src/index.ts` exports the fork-owned `prism.ts` and
- `prismSnapshot.ts`; `apps/server/src/mcp/McpHttpServer.ts` registers the
- fork-owned Prism toolkit (`toolkits/prism/`) and `GET /api/prism/snapshot`
- (`apps/server/src/prism/`); the threads toolkit's `tools.ts` and
- `handlers.ts` take `spawn_thread(role)` and enforce each role's thread-tool
- scope (`toolkits/threads/roles.ts`);
- `apps/server/src/provider/Drivers/OpenCodeDriver.ts` turns on interval
- refresh so OpenCode usage windows stay current.
-- `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle
- self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`.
-- `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory
- so the test passes under macOS's symlinked `TMPDIR`.
-- `.github/workflows/ci.yml`, `.github/workflows/mobile-fingerprint-check.yml`:
- `blacksmith-*-ubuntu-2404` runners become `ubuntu-24.04` and
- `blacksmith-*-macos-*` becomes `macos-15` (see CI).
+## Feature inventory and upstream edits
+
+[The feature map](fork-features.md) lists every fork feature, its Issue and PR,
+new files, edited upstream files, shared files and watch keywords. It is the
+canonical inventory used by the checks and the overlap report. Each path in
+`scripts/fork-upstream-edits.txt` has exactly one primary feature owner in the
+map, including historical entries for fork-new files. Shared edits are recorded
+under the other features so the report considers every affected capability.
+
+When adding or removing a feature, update the map and allowlist in the same PR.
+`scripts/fork-check.sh` rejects unallowlisted upstream edits, unmapped allowlist
+entries, duplicate owners and malformed feature metadata.
## Chromeria desktop app
@@ -152,10 +92,29 @@ Gotchas:
## Routine
`scripts/fork-rebase.sh` fetches `upstream`, rebases the current branch's
-stack onto `upstream/main`, runs the fork check, optionally runs the full
+stack onto `upstream/main` after printing an overlap report, runs the fork check, optionally runs the full
proof, and optionally pushes. It reports conflicts per upstream file and never
force-pushes a published branch without proof passing in the same run.
+Before any rebase, the routine scans every upstream commit from the current
+merge base (the last absorbed upstream commit) to the fetched target. It groups
+matches by feature, using exact touched paths and case-insensitive literal watch
+keywords in commit titles, touched paths and diffs. Shared paths are matched for
+each feature that uses them. Merge commits are compared with their first parent.
+The report includes full base, target and matching commit SHAs, match reasons and
+a decision placeholder for each feature/commit pair.
+
+Run `scripts/fork-rebase.sh --dry-run` first and retain its output in the
+absorption PR, before absorbing. For every match record one decision with a
+rationale: **keep ours**, **adopt upstream and delete ours**, or **merge both**.
+For adoption, name the fork paths removed; for a merge, describe the single
+combined behavior and its proof. Review the report even when Git finds no
+conflicts. No matches does not establish that no semantic overlap exists.
+Do not approve or land an absorption PR with pending decisions. After conflict
+resolution, retain the original report: recomputing from the new merge base
+would omit the just-absorbed commits. A report can also be reproduced without
+fetching or rebasing using `node scripts/fork-features.mjs report `.
+
The proof runs in a host-neutral environment, because upstream's tests assume
CI's Linux host. On a Mac, run from a T3 thread, about 50 upstream tests
otherwise fail without any fork change: T3 Code desktop leaks
@@ -230,7 +189,8 @@ runs `scripts/fork-check.sh`, which verifies:
- the stack is small (at most 20 commits),
- every modification to an upstream-owned file is allowlisted in
`scripts/fork-upstream-edits.txt` (new fork-only files are always fine),
-- this document exists.
+- this document and a valid feature map exist, with exactly one feature owner
+ for every allowlisted upstream edit.
## Absorption log
diff --git a/knip.jsonc b/knip.jsonc
index 5e87b4d5bc55..e991e4b3dcb0 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -14,7 +14,7 @@
},
"scripts": {
// Knip loads its preprocessor through a CLI option; native verification runs directly.
- "entry": ["knip-schemas.ts", "mobile-native-client.ts"],
+ "entry": ["knip-schemas.ts", "mobile-native-client.ts", "fork-features.mjs"],
},
"apps/server": {
// Vite+ pack entries and the launcher used by installed background services.
diff --git a/scripts/fork-check.sh b/scripts/fork-check.sh
index 9eeab392e583..851c9d843550 100755
--- a/scripts/fork-check.sh
+++ b/scripts/fork-check.sh
@@ -79,8 +79,10 @@ while IFS= read -r path; do
done < <(git diff --name-only "$BASE"..HEAD)
if [[ "$FAIL" -ne 0 ]]; then
- echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and docs/fork.md, or move the change to a new file." >&2
+ echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and assign its owner in docs/fork-features.md, or move the change to a new file." >&2
exit 1
fi
+node "$(dirname "$0")/fork-features.mjs" check
+
echo "fork-check: OK."
diff --git a/scripts/fork-features.mjs b/scripts/fork-features.mjs
new file mode 100644
index 000000000000..fd88295d8262
--- /dev/null
+++ b/scripts/fork-features.mjs
@@ -0,0 +1,158 @@
+// Dependency-free so the stack check also runs before pnpm install in fork CI.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+function git(...args) {
+ return NodeChildProcess.execFileSync("git", args, {
+ encoding: "utf8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+}
+
+function loadFeatures(root) {
+ const text = NodeFS.readFileSync(NodePath.join(root, "docs/fork-features.md"), "utf8");
+ const features = [...text.matchAll(/^```json\r?\n([\s\S]*?)^```\s*$/gm)].map((m) =>
+ JSON.parse(m[1]),
+ );
+ if (!features.length) throw new Error("docs/fork-features.md has no feature entries");
+ const ids = new Set();
+ for (const feature of features) {
+ if (
+ typeof feature.id !== "string" ||
+ !/^[a-z][a-z0-9-]*$/.test(feature.id) ||
+ ids.has(feature.id)
+ ) {
+ throw new Error(`invalid or duplicate feature id: ${feature.id}`);
+ }
+ ids.add(feature.id);
+ if (typeof feature.purpose !== "string" || !feature.purpose.trim()) {
+ throw new Error(`${feature.id}: missing purpose`);
+ }
+ for (const field of ["issues", "prs", "newFiles", "upstreamFiles", "sharedFiles", "keywords"]) {
+ const values = feature[field];
+ if (!Array.isArray(values) || values.some((v) => typeof v !== "string" || !v.trim())) {
+ throw new Error(`${feature.id}: invalid ${field}`);
+ }
+ if (new Set(values).size !== values.length) {
+ throw new Error(`${feature.id}: duplicate ${field}`);
+ }
+ }
+ if (!feature.issues.length || !feature.prs.length || !feature.keywords.length) {
+ throw new Error(`${feature.id}: Issue, PR and watch keywords are required`);
+ }
+ }
+ const allowlist = NodeFS.readFileSync(
+ NodePath.join(root, "scripts/fork-upstream-edits.txt"),
+ "utf8",
+ )
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#"));
+ for (const path of new Set(allowlist)) {
+ const owners = features.filter((feature) => feature.upstreamFiles.includes(path));
+ if (owners.length !== 1) {
+ throw new Error(`${path}: expected exactly one feature owner, found ${owners.length}`);
+ }
+ }
+ for (const feature of features) {
+ for (const path of feature.upstreamFiles) {
+ if (!allowlist.includes(path))
+ throw new Error(`${feature.id}: path not allowlisted: ${path}`);
+ }
+ }
+ return features;
+}
+
+// Escape commit subjects/keywords before placing them in Markdown table cells.
+function cell(value) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll("|", "|")
+ .replaceAll("`", "`")
+ .replaceAll("\n", " ");
+}
+
+function report(features, baseRef, targetRef) {
+ const base = git("rev-parse", "--verify", `${baseRef}^{commit}`).trim();
+ const target = git("rev-parse", "--verify", `${targetRef}^{commit}`).trim();
+ git("merge-base", "--is-ancestor", base, target);
+ const commits = git("rev-list", "--reverse", `${base}..${target}`)
+ .trim()
+ .split("\n")
+ .filter(Boolean);
+ const matches = new Map(features.map((f) => [f.id, []]));
+ for (const sha of commits) {
+ const title = git("show", "-s", "--format=%s", sha).trim();
+ // Compare every commit to its first parent, including merge commits. Disable
+ // renames so both old and new paths are visible, and external diff drivers.
+ const paths = git("diff", "--no-ext-diff", "--no-renames", "--name-only", "-z", `${sha}^`, sha)
+ .split("\0")
+ .filter(Boolean);
+ const patch = git(
+ "diff",
+ "--no-ext-diff",
+ "--no-textconv",
+ "--no-renames",
+ "--unified=0",
+ `${sha}^`,
+ sha,
+ );
+ const haystacks = {
+ title: title.toLowerCase(),
+ files: paths.join("\n").toLowerCase(),
+ diff: patch.toLowerCase(),
+ };
+ for (const feature of features) {
+ const reasons = [];
+ const files = new Set([
+ ...feature.newFiles,
+ ...feature.upstreamFiles,
+ ...feature.sharedFiles,
+ ]);
+ for (const path of paths) if (files.has(path)) reasons.push(`file: ${path}`);
+ for (const keyword of feature.keywords) {
+ const sources = Object.entries(haystacks)
+ .filter(([, value]) => value.includes(keyword.toLowerCase()))
+ .map(([source]) => source);
+ if (sources.length) reasons.push(`keyword (${sources.join(", ")}): ${keyword}`);
+ }
+ if (reasons.length) matches.get(feature.id).push({ sha, title, reasons });
+ }
+ }
+ console.log(
+ `# Upstream overlap report\n\nBase: ${base}\n\nTarget: ${target}\n\nScanned ${commits.length} upstream commit(s).`,
+ );
+ console.log(
+ "\nCopy this report into the absorption PR. For EVERY match replace PENDING with one decision and a rationale: keep ours; adopt upstream and delete ours; merge both. Record removed paths or the combined behavior and proof. No matches is not proof of no semantic overlap.",
+ );
+ for (const feature of features) {
+ console.log(`\n## ${feature.id}\n\n${feature.purpose}`);
+ const rows = matches.get(feature.id);
+ if (!rows.length) {
+ console.log("\nNo matches.");
+ continue;
+ }
+ console.log("\n| Commit | Match evidence | Decision and rationale |\n| --- | --- | --- |");
+ for (const row of rows) {
+ console.log(`| ${row.sha} ${cell(row.title)} | ${cell(row.reasons.join("; "))} | PENDING |`);
+ }
+ }
+}
+
+try {
+ const [command, ...args] = process.argv.slice(2);
+ const root = git("rev-parse", "--show-toplevel").trim();
+ const features = loadFeatures(root);
+ if (command === "check" && args.length === 0) {
+ console.log(`fork-features: OK (${features.length} features).`);
+ } else if (command === "report" && args.length === 2) {
+ report(features, ...args);
+ } else {
+ throw new Error("usage: node scripts/fork-features.mjs check | report ");
+ }
+} catch (error) {
+ console.error(`fork-features: FAIL: ${error.message}`);
+ process.exitCode = 1;
+}
diff --git a/scripts/fork-maintenance.test.ts b/scripts/fork-maintenance.test.ts
index 41ea31c64b44..3d4b65fe0f31 100644
--- a/scripts/fork-maintenance.test.ts
+++ b/scripts/fork-maintenance.test.ts
@@ -4,18 +4,43 @@ import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
-import { describe, expect, it } from "vite-plus/test";
+import { afterEach, describe, expect, it } from "vite-plus/test";
const scriptsDir = import.meta.dirname;
const forkCheck = NodePath.join(scriptsDir, "fork-check.sh");
+const forkFeatures = NodePath.join(scriptsDir, "fork-features.mjs");
const forkRebase = NodePath.join(scriptsDir, "fork-rebase.sh");
function git(cwd: string, ...args: Array): string {
return NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" });
}
+function writeMap(root: string, upstreamFiles = ["scripts/fork-upstream-edits.txt"], extra = {}) {
+ const feature = {
+ id: "fixture",
+ purpose: "Protect the fixture capability.",
+ issues: ["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/toolboxmd/t3code/issues/20"],
+ prs: ["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/toolboxmd/t3code/pull/9"],
+ newFiles: ["new-capability.txt"],
+ upstreamFiles,
+ sharedFiles: ["watched.txt"],
+ keywords: ["child thread", "parentThreadId"],
+ ...extra,
+ };
+ NodeFS.writeFileSync(
+ NodePath.join(root, "docs/fork-features.md"),
+ "# Features\n\n```json\n" + JSON.stringify(feature) + "\n```\n",
+ );
+}
+
+const fixtures: Array = [];
+afterEach(() => {
+ for (const root of fixtures.splice(0)) NodeFS.rmSync(root, { recursive: true, force: true });
+});
+
function makeFixture(): string {
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-fork-check-"));
+ fixtures.push(root);
git(root, "init", "-q", "-b", "work");
git(root, "config", "user.email", "fork-check@test");
git(root, "config", "user.name", "fork-check");
@@ -27,6 +52,7 @@ function makeFixture(): string {
"# allowlist\nscripts/fork-upstream-edits.txt\n",
);
NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "base\n");
+ writeMap(root);
git(root, "add", "-A");
git(root, "commit", "-qm", "base");
return root;
@@ -80,6 +106,7 @@ describe("fork-check", () => {
);
git(root, "add", "-A");
git(root, "commit", "-qm", "fork: allowlist the edit");
+ writeMap(root, ["scripts/fork-upstream-edits.txt", "upstream-owned.txt"]);
const allowed = runCheck(root, "--base", base);
expect(allowed.status).toBe(0);
expect(allowed.output).toContain("modified (allowlisted): upstream-owned.txt");
@@ -146,3 +173,120 @@ describe("fork-rebase safety rules", () => {
}
});
});
+
+describe("fork feature ownership", () => {
+ it("rejects an allowlisted path without an owner", () => {
+ const root = makeFixture();
+ writeMap(root, []);
+ const result = runCheck(root, "--base", baseOf(root));
+ expect(result.status).toBe(1);
+ expect(result.output).toContain("expected exactly one feature owner, found 0");
+ });
+
+ it("rejects two owners and duplicate feature ids", () => {
+ const root = makeFixture();
+ const path = NodePath.join(root, "docs/fork-features.md");
+ const original = NodeFS.readFileSync(path, "utf8");
+ NodeFS.appendFileSync(path, original.replace('"fixture"', '"second"'));
+ expect(runCheck(root, "--base", baseOf(root)).output).toContain("found 2");
+ NodeFS.writeFileSync(path, original + original);
+ expect(runCheck(root, "--base", baseOf(root)).output).toContain("duplicate feature id");
+ });
+
+ it("rejects missing or malformed maps and stale ownership", () => {
+ const root = makeFixture();
+ const path = NodePath.join(root, "docs/fork-features.md");
+ NodeFS.rmSync(path);
+ expect(runCheck(root, "--base", baseOf(root)).status).toBe(1);
+ NodeFS.writeFileSync(path, "```json\n{broken}\n```\n");
+ expect(runCheck(root, "--base", baseOf(root)).status).toBe(1);
+ writeMap(root, undefined, { id: undefined });
+ expect(runCheck(root, "--base", baseOf(root)).output).toContain(
+ "invalid or duplicate feature id",
+ );
+ writeMap(root, ["scripts/fork-upstream-edits.txt", "stale.txt"]);
+ expect(runCheck(root, "--base", baseOf(root)).output).toContain(
+ "path not allowlisted: stale.txt",
+ );
+ });
+});
+
+function overlap(root: string, base: string, target = "HEAD"): string {
+ return NodeChildProcess.execFileSync("node", [forkFeatures, "report", base, target], {
+ cwd: root,
+ encoding: "utf8",
+ });
+}
+
+function commitFile(root: string, path: string, content: string, title: string): string {
+ NodeFS.writeFileSync(NodePath.join(root, path), content);
+ git(root, "add", "-A");
+ git(root, "commit", "-qm", title);
+ return baseOf(root);
+}
+
+describe("upstream overlap report", () => {
+ it("matches titles, changed paths and diffs while excluding old and unrelated commits", () => {
+ const root = makeFixture();
+ commitFile(root, "old.txt", "old", "old child thread");
+ const base = baseOf(root);
+ const title = commitFile(root, "one.txt", "plain", "CHILD THREAD support");
+ const path = commitFile(root, "watched.txt", "plain", "touch watched file");
+ const diff = commitFile(root, "two.txt", "parentThreadId", "add linkage");
+ const added = commitFile(root, "new-capability.txt", "plain", "new capability");
+ const unrelated = commitFile(root, "other.txt", "plain", "unrelated");
+ const report = overlap(root, base);
+ for (const sha of [title, path, diff, added]) expect(report).toContain(sha);
+ expect(report).toContain("keyword (title): child thread");
+ expect(report).toContain("file: watched.txt");
+ expect(report).toContain("keyword (diff): parentThreadId");
+ expect(report).not.toContain("old child thread");
+ expect(report).not.toContain(`${unrelated} unrelated`);
+ expect(report).toContain("PENDING");
+ expect(report).toContain("adopt upstream and delete ours");
+ expect(overlap(root, base, base)).toContain("No matches.");
+ });
+
+ it("matches deleted/renamed paths and merge changes and groups shared files", () => {
+ const root = makeFixture();
+ commitFile(root, "watched.txt", "original", "seed");
+ const base = baseOf(root);
+ const map = NodePath.join(root, "docs/fork-features.md");
+ const original = NodeFS.readFileSync(map, "utf8");
+ writeMap(root, [], { id: "second" });
+ NodeFS.appendFileSync(map, original);
+ git(root, "mv", "watched.txt", "renamed.txt");
+ git(root, "commit", "-qam", "rename capability");
+ const renamed = baseOf(root);
+ git(root, "checkout", "-qb", "side");
+ const side = commitFile(root, "side.txt", "parentThreadId", "side linkage");
+ git(root, "checkout", "-q", "work");
+ commitFile(root, "main.txt", "plain", "advance");
+ git(root, "merge", "--no-ff", "-qm", "merge feature", "side");
+ const merged = baseOf(root);
+ const report = overlap(root, base);
+ expect(report).toContain("## second");
+ expect(report).toContain("## fixture");
+ for (const sha of [renamed, side, merged]) expect(report).toContain(sha);
+ expect(report).toContain("file: watched.txt");
+ });
+
+ it("prints the report before rebase and leaves HEAD unchanged in a dry run", () => {
+ const root = makeFixture();
+ const base = baseOf(root);
+ git(root, "checkout", "-qb", "main");
+ commitFile(root, "feature.txt", "parentThreadId", "upstream capability");
+ git(root, "checkout", "-q", "work");
+ git(root, "remote", "add", "upstream", root);
+ const report = NodeChildProcess.execFileSync("bash", [forkRebase, "--dry-run"], {
+ cwd: root,
+ encoding: "utf8",
+ });
+ expect(report).toContain("# Upstream overlap report");
+ expect(report).toContain("upstream capability");
+ expect(report.indexOf("# Upstream overlap report")).toBeLessThan(
+ report.indexOf("dry run; nothing changed"),
+ );
+ expect(baseOf(root)).toBe(base);
+ });
+});
diff --git a/scripts/fork-rebase.sh b/scripts/fork-rebase.sh
index dac55e0d7c67..d1d88ef4ac1e 100755
--- a/scripts/fork-rebase.sh
+++ b/scripts/fork-rebase.sh
@@ -63,6 +63,9 @@ if [[ "$PENDING" -gt 0 ]]; then
git log --oneline "$BASE".."$TARGET" | head -n 20 || true
fi
+echo "fork-rebase: upstream overlap report (before absorption):"
+node "$(dirname "$0")/fork-features.mjs" report "$BASE" "$TARGET"
+
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "fork-rebase: dry run; nothing changed."
exit 0
diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt
index c87d8452e1d9..3f3e98220a0e 100644
--- a/scripts/fork-upstream-edits.txt
+++ b/scripts/fork-upstream-edits.txt
@@ -2,8 +2,8 @@
# One repo-relative path per line. Lines starting with # and blank lines are ignored.
# New fork-only files never need listing here; only modifications (or deletions)
# of files that already exist on the upstream base must be listed.
-# scripts/fork-check.sh enforces this list; keep "Upstream edits" in docs/fork.md
-# describing the same files.
+# scripts/fork-check.sh enforces this list and exactly one primary feature owner
+# per path in docs/fork-features.md. Shared feature usage belongs in sharedFiles.
# Chromeria branding (#12, #13)
apps/desktop/package.json
@@ -55,3 +55,6 @@ packages/contracts/src/settings.ts
apps/web/src/components/settings/SettingsSidebarNav.tsx
apps/web/src/components/settings/settingsSearch.ts
apps/web/src/routeTree.gen.ts
+
+# Fork maintenance script entry point (#20)
+knip.jsonc