diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..212a185 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Check migration sequence + run: npm run migrations:check + + - name: Validate graph + run: npm run memory:graph:validate + + - name: Run tests + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c1efb..9f3547b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +## 0.3.1 - 2026-05-27 + +PAM 0.3.1 adds automated semantic migration enforcement. The graph schema +remains `pam-graph-v1`; this release adds a policy/tooling migration so future +operational, runtime, agent, graph, and tool changes must declare a versioned +migration path. + +### Added + +- Migration enforcement command: `npm run migrations:check`. +- CI-friendly checks for: + - matching `package.json` and `memory/pam.version.json` versions; + - valid semver migration filenames; + - one-step patch/minor/major migration transitions; + - contiguous migration chains from the base version to the target version; + - migration-sensitive changes without a PAM version bump. +- Migration-sensitive path policy for PAM runtime/tooling, graph files, agent + instructions, OpenClaw operational docs, and package metadata. +- Test coverage for semantic migration path validation. + +### Compatibility + +- `memoryFormat` remains `graph-v1`. +- `graphSchemaVersion` remains `pam-graph-v1`. +- Existing 0.3.0 graph JSONL files remain valid. + ## 0.3.0 - 2026-05-08 PAM 0.3.0 adds an OpenClaw specialization profile and makes installation diff --git a/memory/pam.version.json b/memory/pam.version.json index 5f7d27e..ad068af 100644 --- a/memory/pam.version.json +++ b/memory/pam.version.json @@ -1,5 +1,5 @@ { - "pamVersion": "0.3.0", + "pamVersion": "0.3.1", "memoryFormat": "graph-v1", "graphSchemaVersion": "pam-graph-v1", "features": { @@ -7,7 +7,8 @@ "markdownLogs": true, "versionAwareMigration": true, "openClawSpecialization": true, - "installationAcceptanceCriteria": true + "installationAcceptanceCriteria": true, + "migrationEnforcement": true }, - "updated": "2026-05-08" + "updated": "2026-05-27" } diff --git a/migrations/0.3.0-to-0.3.1-migration-enforcement.md b/migrations/0.3.0-to-0.3.1-migration-enforcement.md new file mode 100644 index 0000000..1b938e2 --- /dev/null +++ b/migrations/0.3.0-to-0.3.1-migration-enforcement.md @@ -0,0 +1,47 @@ +# PAM 0.3.0 -> 0.3.1 Migration: Semantic Migration Enforcement + +PAM 0.3.1 adds automated enforcement for semantic migration sequencing. This is +an operational/tooling migration; it does not change `pam-graph-v1`. + +## Who Should Apply This + +Apply this migration when a workspace is on PAM 0.3.0 and wants future PAM +updates to be applied as ordered semantic migrations instead of ad hoc release +notes. + +## Changes + +- Update `package.json` and `memory/pam.version.json` to `0.3.1`. +- Enable the `migrationEnforcement` feature flag. +- Add `npm run migrations:check`. +- Treat runtime/tooling, graph, agent-instruction, OpenClaw operational docs, + and package metadata changes as migration-sensitive. + +## Procedure + +1. Update the workspace to PAM 0.3.1. +2. Run `npm run migrations:check`. +3. If the check fails because a local branch changes migration-sensitive files, + either add the missing semver migration or explicitly split non-migration + docs-only changes into a separate branch. +4. Run the normal graph validation: + + ```bash + npm run memory:graph:validate + ``` + +## Validation + +The migration is complete when: + +- `package.json` version is `0.3.1`. +- `memory/pam.version.json` has `pamVersion: "0.3.1"`. +- `memory/pam.version.json` has `features.migrationEnforcement: true`. +- `npm run migrations:check` passes. +- `npm run memory:graph:validate` passes. + +## Compatibility + +- `memoryFormat` remains `graph-v1`. +- `graphSchemaVersion` remains `pam-graph-v1`. +- Existing 0.3.0 graph JSONL files remain valid. diff --git a/package.json b/package.json index 54f4742..943aa29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portable-agent-memory", - "version": "0.3.0", + "version": "0.3.1", "private": false, "description": "Markdown-first portable memory, wiki, and maintenance toolkit for AI agents.", "type": "module", @@ -18,6 +18,7 @@ "memory:index": "node tools/memory-maintenance.mjs index", "memory:synthesis": "node tools/memory-maintenance.mjs synthesis", "memory:schedule:install": "node tools/install-memory-maintenance-schedule.mjs", + "migrations:check": "node tools/check-migrations.mjs", "test": "node --test tools/test-*.mjs" }, "keywords": [ diff --git a/tools/check-migrations.mjs b/tools/check-migrations.mjs new file mode 100644 index 0000000..d23dae5 --- /dev/null +++ b/tools/check-migrations.mjs @@ -0,0 +1,271 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const WORKSPACE_ROOT = path.resolve(__dirname, ".."); + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const MIGRATION_RE = /^(\d+\.\d+\.\d+)-to-(\d+\.\d+\.\d+)-[a-z0-9][a-z0-9-]*\.md$/; +const DEFAULT_BASE_REF = "origin/main"; + +const MIGRATION_SENSITIVE_PATTERNS = [ + /^memory\/pam\.version\.json$/, + /^memory\/agent-memory\//, + /^memory\/graph\//, + /^tools\//, + /^docs\/openclaw-/, + /^AGENT_BOOTSTRAP\.md$/, + /^AGENTS\.md$/, + /^\.claude-plugin\//, + /^\.claude\//, + /^hooks\//, + /^package\.json$/ +]; + +function readJson(root, relativePath) { + return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8")); +} + +function compareSemver(a, b) { + const left = a.split(".").map(Number); + const right = b.split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if (left[i] !== right[i]) { + return left[i] - right[i]; + } + } + return 0; +} + +function incrementIsValid(from, to) { + const a = from.split(".").map(Number); + const b = to.split(".").map(Number); + if (b[0] === a[0] && b[1] === a[1] && b[2] === a[2] + 1) { + return true; + } + if (b[0] === a[0] && b[1] === a[1] + 1 && b[2] === 0) { + return true; + } + if (b[0] === a[0] + 1 && b[1] === 0 && b[2] === 0) { + return true; + } + return false; +} + +function listMigrationFiles(root) { + const migrationDir = path.join(root, "migrations"); + if (!fs.existsSync(migrationDir)) { + return []; + } + return fs.readdirSync(migrationDir).filter((name) => name.endsWith(".md")).sort(); +} + +function parseSemverMigrations(files) { + return files.flatMap((name) => { + const match = MIGRATION_RE.exec(name); + if (!match) { + return []; + } + return [{ name, from: match[1], to: match[2] }]; + }); +} + +function getJsonAtRef(root, ref, relativePath) { + try { + const content = execFileSync("git", ["show", `${ref}:${relativePath}`], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + return JSON.parse(content); + } catch { + return null; + } +} + +function listChangedFiles(root, baseRef) { + try { + const mergeBase = execFileSync("git", ["merge-base", baseRef, "HEAD"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + const output = execFileSync("git", ["diff", "--name-only", `${mergeBase}...HEAD`], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + const worktreeOutput = execFileSync("git", ["diff", "--name-only", "HEAD"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + const untrackedOutput = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }); + return [ + ...new Set(`${output}\n${worktreeOutput}\n${untrackedOutput}`.split("\n").map((line) => line.trim()).filter(Boolean)) + ]; + } catch { + return []; + } +} + +function isMigrationSensitive(relativePath) { + if (relativePath.startsWith("migrations/")) { + return false; + } + if (relativePath === "CHANGELOG.md" || relativePath === "README.md") { + return false; + } + return MIGRATION_SENSITIVE_PATTERNS.some((pattern) => pattern.test(relativePath)); +} + +function buildMigrationPath(migrations, fromVersion, toVersion) { + const byFrom = new Map(); + for (const migration of migrations) { + if (!byFrom.has(migration.from)) { + byFrom.set(migration.from, []); + } + byFrom.get(migration.from).push(migration); + } + + const pathItems = []; + let current = fromVersion; + const seen = new Set(); + while (compareSemver(current, toVersion) < 0) { + if (seen.has(current)) { + return null; + } + seen.add(current); + const next = (byFrom.get(current) ?? []) + .filter((migration) => compareSemver(migration.to, current) > 0) + .sort((a, b) => compareSemver(a.to, b.to))[0]; + if (!next || compareSemver(next.to, toVersion) > 0) { + return null; + } + pathItems.push(next); + current = next.to; + } + return current === toVersion ? pathItems : null; +} + +function checkMigrations(root = WORKSPACE_ROOT, options = {}) { + const baseRef = options.baseRef ?? process.env.PAM_MIGRATIONS_BASE_REF ?? DEFAULT_BASE_REF; + const packageJson = readJson(root, "package.json"); + const pamVersion = readJson(root, "memory/pam.version.json"); + const files = listMigrationFiles(root); + const migrations = parseSemverMigrations(files); + const changedFiles = options.changedFiles ?? listChangedFiles(root, baseRef); + const basePackage = options.basePackage ?? getJsonAtRef(root, baseRef, "package.json"); + const basePamVersion = options.basePamVersion ?? getJsonAtRef(root, baseRef, "memory/pam.version.json"); + const errors = []; + const warnings = []; + + if (!SEMVER_RE.test(packageJson.version)) { + errors.push(`package.json version is not semver: ${packageJson.version}`); + } + if (!SEMVER_RE.test(pamVersion.pamVersion)) { + errors.push(`memory/pam.version.json pamVersion is not semver: ${pamVersion.pamVersion}`); + } + if (packageJson.version !== pamVersion.pamVersion) { + errors.push(`package.json version (${packageJson.version}) must match pamVersion (${pamVersion.pamVersion})`); + } + + for (const name of files) { + if (/^\d+\.\d+\.\d+-to-/.test(name) && !MIGRATION_RE.test(name)) { + errors.push(`migration filename is invalid: migrations/${name}`); + } + } + + const targetVersions = new Map(); + for (const migration of migrations) { + if (!incrementIsValid(migration.from, migration.to)) { + errors.push(`migration skips a semver step: migrations/${migration.name}`); + } + const previous = targetVersions.get(migration.to); + if (previous) { + errors.push(`multiple migrations target ${migration.to}: migrations/${previous}, migrations/${migration.name}`); + } else { + targetVersions.set(migration.to, migration.name); + } + if (compareSemver(migration.to, pamVersion.pamVersion) > 0) { + errors.push(`migration targets future version ${migration.to}: migrations/${migration.name}`); + } + } + + const baseVersion = basePamVersion?.pamVersion ?? basePackage?.version ?? null; + const versionChanged = Boolean(baseVersion && baseVersion !== pamVersion.pamVersion); + const sensitiveChanges = changedFiles.filter(isMigrationSensitive); + + if (sensitiveChanges.length > 0 && !versionChanged) { + errors.push( + `migration-sensitive files changed without a PAM version bump from ${baseRef}: ${sensitiveChanges.join(", ")}` + ); + } + + if (versionChanged) { + const pathItems = buildMigrationPath(migrations, baseVersion, pamVersion.pamVersion); + if (!pathItems) { + errors.push(`missing contiguous migration path from ${baseVersion} to ${pamVersion.pamVersion}`); + } + } else if (changedFiles.length === 0) { + warnings.push("no git diff against base ref; only repository consistency was checked"); + } + + return { + ok: errors.length === 0, + baseRef, + currentVersion: pamVersion.pamVersion, + baseVersion, + changedFiles, + sensitiveChanges, + migrations, + errors, + warnings + }; +} + +export { + MIGRATION_SENSITIVE_PATTERNS, + buildMigrationPath, + checkMigrations, + compareSemver, + incrementIsValid, + isMigrationSensitive, + parseSemverMigrations +}; + +function main() { + const result = checkMigrations(); + if (process.argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (result.ok) { + process.stdout.write(`migration check OK for PAM ${result.currentVersion}\n`); + for (const warning of result.warnings) { + process.stdout.write(`WARN: ${warning}\n`); + } + } else { + process.stderr.write("migration check failed:\n"); + for (const error of result.errors) { + process.stderr.write(`- ${error}\n`); + } + } + if (!result.ok) { + process.exitCode = 1; + } +} + +if (process.argv[1] === __filename) { + try { + main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/tools/test-check-migrations.mjs b/tools/test-check-migrations.mjs new file mode 100644 index 0000000..43437ba --- /dev/null +++ b/tools/test-check-migrations.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildMigrationPath, + checkMigrations, + incrementIsValid, + isMigrationSensitive, + parseSemverMigrations +} from "./check-migrations.mjs"; + +function makeWorkspace() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "portable-agent-memory-migrations-check-")); + fs.mkdirSync(path.join(root, "memory"), { recursive: true }); + fs.mkdirSync(path.join(root, "migrations"), { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify({ name: "pam-test", version: "0.5.0" }, null, 2)}\n` + ); + fs.writeFileSync( + path.join(root, "memory", "pam.version.json"), + `${JSON.stringify({ pamVersion: "0.5.0" }, null, 2)}\n` + ); + fs.writeFileSync(path.join(root, "migrations", "0.3.0-to-0.4.0-agent-layer.md"), "# 0.4.0\n"); + fs.writeFileSync( + path.join(root, "migrations", "0.4.0-to-0.4.1-openclaw-daily-maintenance.md"), + "# 0.4.1\n" + ); + fs.writeFileSync(path.join(root, "migrations", "0.4.1-to-0.5.0-file-only-coverage.md"), "# 0.5.0\n"); + return root; +} + +test("accepts patch, minor, and major one-step semver migrations", () => { + assert.equal(incrementIsValid("0.4.0", "0.4.1"), true); + assert.equal(incrementIsValid("0.4.1", "0.5.0"), true); + assert.equal(incrementIsValid("0.9.1", "1.0.0"), true); + assert.equal(incrementIsValid("0.3.0", "0.5.0"), false); + assert.equal(incrementIsValid("0.4.1", "0.5.1"), false); +}); + +test("parses only semver-to-semver migration guides", () => { + const migrations = parseSemverMigrations([ + "0.3.0-to-0.4.0-agent-layer.md", + "markdown-v0-to-graph-v1.md" + ]); + assert.deepEqual(migrations, [ + { + name: "0.3.0-to-0.4.0-agent-layer.md", + from: "0.3.0", + to: "0.4.0" + } + ]); +}); + +test("builds a contiguous semantic migration path", () => { + const migrations = parseSemverMigrations([ + "0.3.0-to-0.4.0-agent-layer.md", + "0.4.0-to-0.4.1-openclaw-daily-maintenance.md", + "0.4.1-to-0.5.0-file-only-coverage.md" + ]); + assert.deepEqual( + buildMigrationPath(migrations, "0.3.0", "0.5.0").map((migration) => migration.name), + [ + "0.3.0-to-0.4.0-agent-layer.md", + "0.4.0-to-0.4.1-openclaw-daily-maintenance.md", + "0.4.1-to-0.5.0-file-only-coverage.md" + ] + ); +}); + +test("marks agent instructions, graph, tools, and OpenClaw docs as migration-sensitive", () => { + assert.equal(isMigrationSensitive("memory/agent-memory/pam-openclaw.md"), true); + assert.equal(isMigrationSensitive("memory/graph/nodes.jsonl"), true); + assert.equal(isMigrationSensitive("tools/pam-mcp-server.mjs"), true); + assert.equal(isMigrationSensitive("docs/openclaw-daily-graph-maintenance.md"), true); + assert.equal(isMigrationSensitive("README.md"), false); + assert.equal(isMigrationSensitive("migrations/0.4.0-to-0.4.1-example.md"), false); +}); + +test("fails when migration-sensitive changes do not bump PAM version", () => { + const root = makeWorkspace(); + const result = checkMigrations(root, { + baseRef: "test-base", + basePamVersion: { pamVersion: "0.5.0" }, + basePackage: { version: "0.5.0" }, + changedFiles: ["docs/openclaw-daily-graph-maintenance.md"] + }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /without a PAM version bump/); +}); + +test("fails when a version bump skips an intermediate migration", () => { + const root = makeWorkspace(); + fs.rmSync(path.join(root, "migrations", "0.4.0-to-0.4.1-openclaw-daily-maintenance.md")); + const result = checkMigrations(root, { + baseRef: "test-base", + basePamVersion: { pamVersion: "0.3.0" }, + basePackage: { version: "0.3.0" }, + changedFiles: ["memory/pam.version.json", "tools/memory-graph.mjs"] + }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /missing contiguous migration path/); +}); + +test("passes when a version bump has a complete migration chain", () => { + const root = makeWorkspace(); + const result = checkMigrations(root, { + baseRef: "test-base", + basePamVersion: { pamVersion: "0.3.0" }, + basePackage: { version: "0.3.0" }, + changedFiles: ["memory/pam.version.json", "tools/memory-graph.mjs"] + }); + assert.equal(result.ok, true); +});