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
25 changes: 25 additions & 0 deletions .agents/pm/chores/pm-github-d2az.toon
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
id: pm-github-d2az
title: Certify pm CLI 2026.9.23 and adopt the guarded pm-ops merge-driver launcher
description: "Fleet wave 2026-09-25 (companion epic pm-cli-website-5s6z, launcher rollout pm-cli-website-xy19). Pins the toolchain to 2026.9.23 and replaces the prepare hook with the launcher template pm-ops ships: it imports nothing from pm-ops, so a production install of a clone (npm ci --omit=dev) skips with one notice instead of failing, while a stale or broken pm-ops still fails the install. A test keeps the copy byte-identical to the pinned template, whose branches pm-ops covers with real fixtures."
type: Chore
status: closed
priority: 1
tags[4]: certify,merge-driver,multi-agent,pm-cli-2026.9.23
created_at: "2026-09-25T08:14:55.781Z"
updated_at: "2026-09-25T08:58:09.880Z"
closed_at: "2026-09-25T08:22:45.260Z"
completed_at: "2026-09-25T08:22:45.260Z"
author: fleet-wave-script
acceptance_criteria: "package.json and package-lock.json pin pm-cli 2026.9.23 and pm-ops 2026.9.23 (pm-changelog 2026.9.23 where used); scripts/prepare-merge-driver.ts is byte-identical to the pinned pm-ops template, enforced by a test; CI runs pm health --strict-exit --require-merge-drivers and release:check exits 0"
comments[1]{created_at,author,text}:
"2026-09-25T08:58:09.880Z",fleet-wave-script,"Review round 1 (Sourcery and Greptile on the wave PRs): the launcher test now builds isolated git-init checkouts, so drivers registered by this repository's own npm ci cannot mask a launcher that registers none; it asserts the full driver set .gitattributes declares, and it covers the omit-dev skip, stale pm-ops, failing and killed installer branches. The launcher stays in the coverage sources where it was, covered through the child processes as in pm-ops."
files[5]{path,scope}:
package-lock.json,project
package.json,project
README.md,project
scripts/prepare-merge-driver.ts,project
test/prepare-merge-driver.test.ts,project
tests[1]{command,scope,provenance{author,created_at,source_kind,source_ref}}:
"npm run release:check",project,fleet-wave-script,"2026-09-25T08:15:08.770Z",local_mutation,certify-pm-cli-2026-9-23-and-roll-out-guarded-merge-driver-launcher
close_reason: "Pins: @unbrained/pm-cli 2026.9.21 -> 2026.9.23, pm-ops 2026.9.18 -> 2026.9.23, pm-changelog 2026.9.18 -> 2026.9.23, @types/node ^26.1.1 -> ^26.6.2 (package.json and package-lock.json). Launcher: pm-ops template copied unchanged, replacing the static-import launcher; test/prepare-merge-driver.test.ts proves byte identity and a real driver install. git config lists the pm merge drivers; the CI health block runs green with --require-merge-drivers; release:check exits 0."
body: ""
12 changes: 12 additions & 0 deletions .agents/pm/history/pm-github-d2az.jsonl

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Other

- Certify pm CLI 2026.9.23 and adopt the guarded pm-ops merge-driver launcher ([pm-github-d2az](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-d2az.toon))

## 2026.9.22 - 2026-09-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ This package is release-ready for GitHub, npm, and Bun-compatible installs. CI r
This repo tracks its project management in `.agents/pm/` and ships a committed `.gitattributes`
that maps those tracker artifacts to pm-cli's field-aware Git merge drivers, so concurrent-branch
tracker edits merge cleanly. The driver definitions live in per-clone Git config; `npm install` /
`npm ci` wires them automatically via the `prepare` script (a portable Node guard, `scripts/prepare-merge-driver.ts`, a thin launcher over `pm-ops/merge-driver`: it runs `pm merge install` only when the `pm` CLI is on `PATH`, and no-ops cleanly when `pm` is absent. Registry installs of this package never run `prepare`; a production install of a clone (`npm ci --omit=dev`) omits `pm-ops` too, so it must pass `--ignore-scripts`; being Node-based it behaves identically on POSIX shells and Windows `cmd.exe`). To (re)run
`npm ci` wires them automatically via the `prepare` script, `scripts/prepare-merge-driver.ts`: the launcher template pm-ops ships, copied unchanged, which a test compares byte for byte with the pinned template. It runs pm-ops's installer, which calls `pm merge install` when the `pm` CLI is on `PATH` and skips with a notice when it is not. A production install of a clone (`npm ci --omit=dev`) has no `pm-ops`, so the launcher skips with one notice, while a stale or broken `pm-ops` fails the install. Registry installs of this package never run `prepare`. Being Node-based, it behaves identically on POSIX shells and Windows `cmd.exe`. To (re)run
manually: `npm run merge:install`.

After merging a branch that touched `.agents/pm/`, reconcile any residual history-hash drift with
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@
"@unbrained/pm-cli": ">=2026.8.20"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@unbrained/pm-cli": "2026.9.21",
"pm-changelog": "2026.9.18",
"pm-ops": "2026.9.18",
"@types/node": "^26.6.2",
"@unbrained/pm-cli": "2026.9.23",
"pm-changelog": "2026.9.23",
"pm-ops": "2026.9.23",
"typescript": "^7.0.2"
},
"keywords": [
Expand Down
44 changes: 38 additions & 6 deletions scripts/prepare-merge-driver.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
/**
* npm `prepare` hook that installs pm's field-aware Git merge drivers.
* npm `prepare` hook that registers pm's field-aware Git merge drivers.
*
* Git never clones `.git/config`, so every clone must register the drivers that
* `.gitattributes` declares. The canonical implementation lives in
* `pm-ops/merge-driver`; this file stays a thin launcher over it.
* Git never clones `.git/config`, so every clone must register the drivers
* `.gitattributes` declares. The installer lives in the devDependency pm-ops,
* which an `npm install --omit=dev` checkout does not have. This launcher
* therefore imports nothing from pm-ops: it resolves the installer entry from
* the package root and runs it in a child process. Only a missing pm-ops package skips, with one
* notice; any other resolution failure (for example a pm-ops too old to export
* the entry) and any installer failure fail the install.
*
* Canonical copy: `pm-ops/templates/prepare-merge-driver.ts`. Copy it
* unchanged to `scripts/prepare-merge-driver.ts`.
*/
import { runPrepareMergeDriver } from "pm-ops/merge-driver";

process.exitCode = runPrepareMergeDriver();
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { join } from "node:path";

// npm runs `prepare` from the package root, so pm-ops is resolved from there.
const resolver = createRequire(join(process.cwd(), "package.json"));
let installer: string | undefined;
try {
installer = resolver.resolve("pm-ops/merge-driver/prepare");
} catch (error) {
// Only an absent pm-ops package may skip. Probing its package.json tells that
// apart from an installed pm-ops that cannot serve the entry (exports without
// it, no exports map, a missing file): those resolve or fail differently, and
// the original error is rethrown.
let packagePresent = true;
try {
resolver.resolve("pm-ops/package.json");
} catch (probe) {
packagePresent = !(probe instanceof Error && "code" in probe && probe.code === "MODULE_NOT_FOUND");
}
if (packagePresent) throw error;
}
if (installer === undefined) {
console.error("pm-ops is not installed (omit-dev install); skipping merge-driver install");
} else {
process.exitCode = spawnSync(process.execPath, [installer], { stdio: "inherit" }).status ?? 1;
}
121 changes: 121 additions & 0 deletions test/prepare-merge-driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Tests for the npm `prepare` hook `scripts/prepare-merge-driver.ts`.
*
* The hook must stay the canonical pm-ops launcher byte for byte, and it is
* exercised the way npm runs it: as the entry point of a child process whose
* working directory is a consumer checkout. Every checkout is a fresh
* `git init` with its own local config, so the drivers this repository's own
* `npm ci` registered cannot mask a launcher that registers none.
*/
import assert from "node:assert/strict";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import test, { after } from "node:test";

// npm runs tests from the package root, which is also where it runs `prepare`.
const root = process.cwd();
const launcher = join(root, "scripts", "prepare-merge-driver.ts");
const hostPath = `${join(root, "node_modules", ".bin")}${delimiter}${process.env.PATH ?? ""}`;
const scratch = mkdtempSync(join(tmpdir(), "prepare-merge-driver-"));
after(() => rmSync(scratch, { recursive: true, force: true }));

// The fixtures use POSIX stub executables and directory symlinks, like pm-ops's own launcher suite.
const posixOnly = { skip: process.platform === "win32" };

/** Every merge driver `.gitattributes` asks Git to use, e.g. `pm-history` from `merge=pm-history`. */
const declaredDrivers = [
...new Set([...readFileSync(join(root, ".gitattributes"), "utf8").matchAll(/\bmerge=([\w-]+)/g)].map((match) => match[1])),
].sort();

/**
* Create a consumer checkout: a fresh Git repository carrying this
* repository's `.gitattributes` and tracker settings. `pmOps` selects what
* `node_modules/pm-ops` is: absent (an omit-dev install), the pinned package,
* or a stale pm-ops whose exports predate the launcher entry.
*/
function checkout(name: string, pmOps: "absent" | "pinned" | "stale"): string {
const directory = join(scratch, name);
mkdirSync(join(directory, ".agents", "pm"), { recursive: true });
assert.equal(spawnSync("git", ["init", "-q"], { cwd: directory }).status, 0);
writeFileSync(join(directory, "package.json"), JSON.stringify({ name, type: "module" }));
copyFileSync(join(root, ".gitattributes"), join(directory, ".gitattributes"));
copyFileSync(join(root, ".agents", "pm", "settings.json"), join(directory, ".agents", "pm", "settings.json"));
if (pmOps === "pinned") {
mkdirSync(join(directory, "node_modules"));
symlinkSync(join(root, "node_modules", "pm-ops"), join(directory, "node_modules", "pm-ops"), "dir");
}
if (pmOps === "stale") {
mkdirSync(join(directory, "node_modules", "pm-ops"), { recursive: true });
writeFileSync(
join(directory, "node_modules", "pm-ops", "package.json"),
JSON.stringify({ name: "pm-ops", type: "module", exports: { "./merge-driver": "./merge-driver.js" } }),
);
}
return directory;
}

/** Put a stub `pm` that runs `body` then exits with `status` alone on a fresh PATH directory. */
function stubPm(name: string, status: number, body = ""): string {
const bin = join(scratch, `${name}-bin`);
mkdirSync(bin);
writeFileSync(join(bin, "pm"), `#!/bin/sh\n${body}\nexit ${status}\n`);
chmodSync(join(bin, "pm"), 0o755);
return bin;
}

/** Run the launcher as npm's `prepare` hook would: as the entry point, from `cwd`, with `path` as PATH. */
function prepare(cwd: string, path: string): SpawnSyncReturns<string> {
return spawnSync(process.execPath, [launcher], { cwd, encoding: "utf8", env: { ...process.env, PATH: path } });
}

/** The merge drivers registered in a checkout's LOCAL Git config, by name. */
function registeredDrivers(cwd: string): string[] {
const config = spawnSync("git", ["config", "--local", "--name-only", "--get-regexp", "^merge\\..*\\.driver$"], {
cwd,
encoding: "utf8",
});
// git exits 1 when no key matches, which is a genuine "none registered".
assert.ok(config.status === 0 || config.status === 1, config.stderr);
return config.stdout.split("\n").filter(Boolean).map((key) => key.split(".")[1]).sort();
}

test("the prepare launcher is the unmodified pm-ops template", () => {
const canonical = readFileSync(join(root, "node_modules", "pm-ops", "templates", "prepare-merge-driver.ts"), "utf8");
assert.equal(readFileSync(launcher, "utf8"), canonical);
});

test("a full install registers every merge driver .gitattributes declares", posixOnly, () => {
const directory = checkout("full", "pinned");
assert.deepEqual(registeredDrivers(directory), []);
const result = prepare(directory, hostPath);
assert.equal(result.status, 0, result.stderr);
assert.ok(declaredDrivers.length > 0, ".gitattributes declares no pm merge drivers");
assert.deepEqual(registeredDrivers(directory), declaredDrivers);
});

test("an omit-dev checkout without pm-ops skips with one notice and registers nothing", posixOnly, () => {
const directory = checkout("omit-dev", "absent");
const result = prepare(directory, hostPath);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, "pm-ops is not installed (omit-dev install); skipping merge-driver install\n");
assert.deepEqual(registeredDrivers(directory), []);
});

test("a pm-ops too old to export the launcher entry fails the install", posixOnly, () => {
const result = prepare(checkout("stale", "stale"), hostPath);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /ERR_PACKAGE_PATH_NOT_EXPORTED/);
});

test("a failing pm merge install fails the install with the same status", posixOnly, () => {
const result = prepare(checkout("failing-pm", "pinned"), stubPm("failing-pm", 7));
assert.equal(result.status, 7, result.stderr);
});

test("an installer killed by a signal fails the install instead of reporting success", posixOnly, () => {
// The stub's parent is the pm-ops installer process the launcher spawned.
const result = prepare(checkout("killed", "pinned"), stubPm("killed", 0, "kill -9 $PPID"));
assert.equal(result.status, 1, result.stderr);
});
Loading