Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ADAPTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ After maintained-source changes, refresh the existing installations with `agy pl
- A file this fork never changed: apply the change, then check it against the decisions below. The 0.15.25 sync rewrote upstream's new fast-mode sentence in two otherwise untouched forensics playbooks.
- A file this fork deleted, such as `.cursor-plugin/plugin.json`, `make-bot-ui`, or the Benny pack: keep it deleted unless the change removes the reason recorded under [Not taken from upstream](#not-taken-from-upstream).

The [fork guards](./docs/ci.md#fork-guards) reject the denylisted Cursor primitives and require each local decision listed in `REQUIRED` in `scripts/check-fork.mjs`, by file and anchor sentence. A decision this guide records without such a rule is guarded only by reading, so read every change against the guide. When a sync changes a guarded decision on purpose, update its section here and its rule in the same commit. Finish a sync by moving `UPSTREAM.json` and the first line of this guide to the script's `latestCommit`, adding a line to the sync log, recording each new or changed decision in its section, running the [verification](#verification) checks, and [refreshing the installations](#refresh-installations).
The [fork guards](./docs/ci.md#fork-guards) reject the denylisted Cursor primitives and require each local decision listed in `REQUIRED` in `scripts/check-fork.mjs`, by file and anchor sentence. A decision this guide records without such a rule is guarded only by reading, so read every change against the guide. When a sync changes a guarded decision on purpose, update its section here and its rule in the same commit. Finish a sync by moving `UPSTREAM.json` to the script's `latestCommit` and `latestVersion` and the other copies of the pin to match, restarting the host manifests at `<version>-shared.1` (the fork guards name each one), adding a line to the sync log, recording each new or changed decision in its section, running the [verification](#verification) checks, and [refreshing the installations](#refresh-installations).

### Not taken from upstream

Expand Down Expand Up @@ -182,7 +182,7 @@ The CLI tests use real Git checkouts and subprocesses with vendor stand-ins, inc

Run `scripts/xagent-e2e.sh` after changing `bin/xagent*` or upgrading an agent CLI; it calls every vendor. Run `python3 scripts/pstack-host-e2e.py <new-run-dir>` after changing cross-agent routing: actual Claude and Codex hosts run swarm, then interrogate, through their current shared policy on synthetic fixtures. The host harness accepts a run only on evidence the host cannot write, such as xagent receipts, the host's own logs, including the commands the host ran itself, and its own independent tests, because the host's success report is the thing under test. Its acceptance rules live in the script's module docstring, beside the code and tests that enforce them, so change them there. Neither harness changes preferences. Both bound the outer host process with `timeout` and a kill-after limit, and a verification timeout is a failed check with exit code 124.

The runtime checks start with `scripts/check-fork.mjs`, which validates the manifests, all SKILL.md and agent frontmatter, each workflow skill's link to the shared runtime contract and its absence from the `STANDALONE` skills, local links, a denylist of the Cursor-only wording this guide replaces, and the files and anchor sentences of the local decisions it requires; see [Fork guards](./docs/ci.md#fork-guards). Use a fresh Codex app-server skills/list and Claude `plugin details` to verify inventory; a static validator alone is not a discovery or behavioral test.
The runtime checks start with `scripts/check-fork.mjs`, which validates the manifests, the copies of the upstream pin, all SKILL.md and agent frontmatter, each workflow skill's link to the shared runtime contract and its absence from the `STANDALONE` skills, local links, a denylist of the Cursor-only wording this guide replaces, and the files and anchor sentences of the local decisions it requires; see [Fork guards](./docs/ci.md#fork-guards). Use a fresh Codex app-server skills/list and Claude `plugin details` to verify inventory; a static validator alone is not a discovery or behavioral test.

Rebuild the `watch-pr` bundle with `python3 scripts/build-runtime.py` when its sources, dependencies or builder change. Use the Bun version in `.bun-version` and locked development dependencies. `python3 scripts/build-runtime.py --check` compares a temporary build with the working tree without writing artifacts. Node runtime helpers never download dependencies.

Expand Down
1 change: 1 addition & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ bash scripts/check.sh build
| Skills | a skill directory lacks `SKILL.md`, a frontmatter `name` differs from its directory or agent file name, a `description` is empty, a `SKILL.md` outside `STANDALONE` in `scripts/check-fork.mjs` has no link to `references/runtime.md`, a `STANDALONE` skill links it, or `STANDALONE` lists a missing skill |
| Links | a Markdown link outside code points at a missing file or leaves the repository |
| Manifests | a manifest is not valid JSON or loses its name (`pstack`, or `pstack-codex` for Codex), or the root `plugin.json` references `agent-plugins.org`, whose `$schema` makes Codex truncate each skill to 8,000 bytes |
| Upstream pin | `UPSTREAM.json` lacks a GitHub repository URL, a path, a full commit SHA, or an `x.y.z` version; the adaptation guide's opening pin sentence, `README.md`, or `README.zh-CN.md` no longer cites that version and commit; or a host manifest's `version` no longer starts with `<upstream version>-shared.` |
| Required | a local decision in the `REQUIRED` table of `scripts/check-fork.mjs` loses its file or anchor sentence, such as the verification-skill replays, xagent, explicit PR, push and merge authorization, fast mode only on request, watch-pr `--once`, or the T3 dispatch contract; or a rule cites an adaptation-guide section that no longer exists |

When the denylist fires after a sync, rewrite the sentence the way the adaptation guide records that decision instead of swapping tokens. A file that names a pattern on purpose, such as an adapter listing the fields it forbids, goes in that rule's `allow` list in `scripts/check-fork.mjs`; `skills/poteto-help/SKILL.md` is the one allowed `disable-model-invocation`. An allowed file that stops matching fails as stale, so an allowance cannot outlive the sentence it excuses.
Expand Down
75 changes: 66 additions & 9 deletions scripts/check-fork.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// Static guards for upstream syncs. Shipped content must not bring back the
// Cursor-only instructions ADAPTATION.md replaced and must keep the local
// decisions it records, every skill outside STANDALONE keeps its shared runtime
// contract, local links resolve, and the manifests keep their fork names.
// contract, local links resolve, the manifests keep their fork names, and every
// copy of the upstream pin matches UPSTREAM.json.
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -517,15 +518,18 @@ export function validateSkills(root) {
return problems;
}

const MANIFEST_NAMES = {
"plugin.json": "pstack",
".claude-plugin/plugin.json": "pstack",
".codex-plugin/plugin.json": "pstack-codex",
// The host manifests version the fork as <upstream version>-shared.<n>; the root
// plugin.json is the Antigravity manifest and carries no version.
const MANIFESTS = {
"plugin.json": { name: "pstack" },
".claude-plugin/plugin.json": { name: "pstack", versioned: true },
".codex-plugin/plugin.json": { name: "pstack-codex", versioned: true },
};

export function validateManifests(root) {
// pin is readPin's result, or null when UPSTREAM.json is malformed, which checkFork reports.
export function validateManifests(root, pin) {
const problems = [];
for (const [rel, name] of Object.entries(MANIFEST_NAMES)) {
for (const [rel, { name, versioned }] of Object.entries(MANIFESTS)) {
let text;
let manifest;
try {
Expand All @@ -536,6 +540,12 @@ export function validateManifests(root) {
continue;
}
if (manifest.name !== name) problems.push(`${rel}: name ${JSON.stringify(manifest.name ?? null)} must be ${JSON.stringify(name)}`);
if (versioned && pin) {
const prefix = `${pin.version}-shared.`;
if (!(typeof manifest.version === "string" && manifest.version.startsWith(prefix))) {
problems.push(`${rel}: upstream-pin: version ${JSON.stringify(manifest.version ?? null)} must start with ${JSON.stringify(prefix)}, after UPSTREAM.json`);
}
}
// An Agent Plugins $schema makes Codex load the checkout as an Agent Plugin,
// which truncates each injected skill to 8,000 bytes (ADAPTATION.md, Not taken from upstream).
if (rel === "plugin.json" && /agent-plugins\.org/i.test(text)) {
Expand All @@ -545,6 +555,46 @@ export function validateManifests(root) {
return problems;
}

// UPSTREAM.json holds the pin. A sync moves it there, and the guards name every
// copy left behind: the sentences in PIN_COPIES and the host manifests' versions.
const PIN_FIELDS = {
repository: { pattern: /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/, want: "https://github.com/<owner>/<repo>" },
path: { pattern: /^[\w/-]+$/, want: "a path of word characters, / and -" },
commit: { pattern: /^[a-f0-9]{40}$/, want: "a full 40-character SHA" },
version: { pattern: /^\d+\.\d+\.\d+$/, want: "x.y.z" },
};

export function readPin(root) {
const pin = JSON.parse(readFileSync(path.join(root, "UPSTREAM.json"), "utf8"));
const malformed = Object.entries(PIN_FIELDS).filter(([field, { pattern }]) => typeof pin?.[field] !== "string" || !pattern.test(pin[field]));
if (malformed.length) throw new Error(`upstream-pin: ${malformed.map(([field, { want }]) => `${field} must be ${want}`).join("; ")}`);
return pin;
}

const PIN_COPIES = {
"ADAPTATION.md": ({ version, commit }) => `Upstream pstack ${version} at \`${commit}\``,
"README.md": ({ version, short, tree }) => `upstream pstack ${version} at [\`${short}\`](${tree})`,
"README.zh-CN.md": ({ version, short, tree }) => `上游 pstack ${version},跟踪提交 [\`${short}\`](${tree})`,
};

// pin is readPin's result.
export function validatePin(root, pin) {
const fields = { ...pin, short: pin.commit.slice(0, 8), tree: `${pin.repository}/tree/${pin.commit}/${pin.path}` };
const problems = [];
for (const [rel, cite] of Object.entries(PIN_COPIES)) {
const expected = cite(fields);
let text;
try {
text = readFileSync(path.join(root, rel), "utf8");
} catch (error) {
problems.push(`${rel}: ${error.message}`);
continue;
}
if (!text.includes(expected)) problems.push(`${rel}: upstream-pin: no ${JSON.stringify(expected)}; cite the version and commit in UPSTREAM.json`);
}
return problems;
}

// GitHub's heading anchors: lowercase, punctuation other than `-` and `_` dropped, spaces to `-`.
function headingSlugs(text) {
return new Set([...text.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*$/gm)].map((m) => m[1].toLowerCase().replace(/[^\w\- ]/g, "").replaceAll(" ", "-")));
Expand All @@ -565,7 +615,14 @@ export function validateRequired(root) {
}

export function checkFork(root) {
return [...validateManifests(root), ...validateSkills(root), ...validateLinks(root), ...scanDenylist(root), ...validateRequired(root)];
let pin = null;
const problems = [];
try {
pin = readPin(root);
} catch (error) {
problems.push(`UPSTREAM.json: ${error.message}`);
}
return [...problems, ...validateManifests(root, pin), ...(pin ? validatePin(root, pin) : []), ...validateSkills(root), ...validateLinks(root), ...scanDenylist(root), ...validateRequired(root)];
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
Expand All @@ -575,5 +632,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
console.error(`Fork guards failed:\n${problems.join("\n")}`);
process.exit(1);
}
console.log(`Fork guards passed: ${DENYLIST.length} denylist rules, ${REQUIRED.length} required local decisions, skill frontmatter and contract links, local Markdown links, manifests.`);
console.log(`Fork guards passed: ${DENYLIST.length} denylist rules, ${REQUIRED.length} required local decisions, skill frontmatter and contract links, local Markdown links, manifests, upstream pin.`);
}
41 changes: 39 additions & 2 deletions scripts/check-fork.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { checkFork, REQUIRED, scanDenylist, validateLinks, validateManifests, validateRequired, validateSkills } from "./check-fork.mjs";
import { checkFork, readPin, REQUIRED, scanDenylist, validateLinks, validateManifests, validateRequired, validateSkills } from "./check-fork.mjs";

const root = path.resolve(import.meta.dirname, "..");
const cli = path.join(root, "scripts/check-fork.mjs");
Expand Down Expand Up @@ -150,7 +150,7 @@ test("Given upstream's Agent Plugins root manifest, When the guards run, Then th
edit(dir, "plugin.json", (text) => text.replace("{\n", '{\n "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",\n'));
edit(dir, ".codex-plugin/plugin.json", (text) => text.replace('"name": "pstack-codex"', '"name": "pstack"'));

assert.deepEqual(validateManifests(dir), [
assert.deepEqual(validateManifests(dir, readPin(dir)), [
"plugin.json: references agent-plugins.org; the root manifest stays the Antigravity manifest without the Agent Plugins $schema",
'.codex-plugin/plugin.json: name "pstack" must be "pstack-codex"',
]);
Expand All @@ -159,6 +159,43 @@ test("Given upstream's Agent Plugins root manifest, When the guards run, Then th
assert.match(failed.stderr, /^plugin\.json: references agent-plugins\.org/m);
});

test("Given a sync that moves only UPSTREAM.json, When guarded, Then each copy still citing the old pin is reported", (t) => {
const dir = copyRepo(t);
const pin = JSON.parse(readFileSync(path.join(dir, "UPSTREAM.json"), "utf8"));
const moved = { ...pin, version: "0.15.99", commit: "0123456789abcdef0123456789abcdef01234567" };
writeFileSync(path.join(dir, "UPSTREAM.json"), `${JSON.stringify(moved, null, 2)}\n`);
const stale = () => checkFork(dir).map((problem) => problem.split(":")[0]);
const manifests = [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"];
assert.deepEqual(stale(), [...manifests, "ADAPTATION.md", "README.md", "README.zh-CN.md"]);
const { version } = JSON.parse(readFileSync(path.join(dir, manifests[0]), "utf8"));
assert.ok(checkFork(dir).includes(`${manifests[0]}: upstream-pin: version "${version}" must start with "0.15.99-shared.", after UPSTREAM.json`),
checkFork(dir).join("\n"));
for (const rel of manifests) edit(dir, rel, (text) => text.replace(`"version": "${pin.version}-shared.`, `"version": "${moved.version}-shared.`));
// Moving one copy clears only that copy, and the README's short form must move with the full commit.
edit(dir, "ADAPTATION.md", (text) => text.replace(`Upstream pstack ${pin.version} at \`${pin.commit}\``, `Upstream pstack ${moved.version} at \`${moved.commit}\``));
edit(dir, "README.md", (text) => text.replace(`pstack ${pin.version} at`, `pstack ${moved.version} at`).replace(`/tree/${pin.commit}/`, `/tree/${moved.commit}/`));
assert.deepEqual(stale(), ["README.md", "README.zh-CN.md"]);
edit(dir, "README.md", (text) => text.replace(`[\`${pin.commit.slice(0, 8)}\`]`, `[\`${moved.commit.slice(0, 8)}\`]`));
assert.deepEqual(stale(), ["README.zh-CN.md"]);
});

test("Given a malformed UPSTREAM.json, When guarded, Then the pin alone is reported", (t) => {
const dir = copyRepo(t);
const pin = JSON.parse(readFileSync(path.join(dir, "UPSTREAM.json"), "utf8"));
for (const [field, value, want] of [
["version", "0.15", "x.y.z"],
["commit", pin.commit.slice(0, 8), "a full 40-character SHA"],
["repository", "https://example.com/cursor/plugins", "https://github.com/<owner>/<repo>"],
["path", "../pstack", "a path of word characters, / and -"],
["path", undefined, "a path of word characters, / and -"],
]) {
writeFileSync(path.join(dir, "UPSTREAM.json"), JSON.stringify({ ...pin, [field]: value }));
assert.deepEqual(checkFork(dir), [`UPSTREAM.json: upstream-pin: ${field} must be ${want}`], `${field}: ${value}`);
}
writeFileSync(path.join(dir, "UPSTREAM.json"), "null");
assert.match(checkFork(dir).join("\n"), /^UPSTREAM\.json: upstream-pin: repository must be .+; version must be x\.y\.z$/);
});

test("Given a clean copy, When the CLI runs on it, Then it exits 0", (t) => {
const dir = copyRepo(t);
const passed = spawnSync(process.execPath, [cli, dir], { encoding: "utf8" });
Expand Down
11 changes: 4 additions & 7 deletions scripts/check-upstream.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
#!/usr/bin/env node
import {readFileSync} from 'node:fs';
import {execFileSync} from 'node:child_process';
import {fileURLToPath} from 'node:url';
import {readPin} from './check-fork.mjs';

const pinned = JSON.parse(readFileSync(new URL('../UPSTREAM.json', import.meta.url), 'utf8'));
const match = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/.exec(pinned.repository);
if (!match || !/^[a-f0-9]{40}$/.test(pinned.commit) || !/^[\w/-]+$/.test(pinned.path) || pinned.path.includes('..')) {
throw new Error('Invalid UPSTREAM.json repository, commit, or path');
}
const repo = match[1];
const pinned = readPin(fileURLToPath(new URL('..', import.meta.url)));
const repo = pinned.repository.slice('https://github.com/'.length);
const api = path => JSON.parse(execFileSync('gh', ['api', path], {encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}));
const PAGE = 100;
const metadata = api(`repos/${repo}`);
Expand Down
Loading
Loading